Deploy aea7fa1 preload summary audio
#1
by themva - opened
- src/App.tsx +212 -38
- src/styles.css +12 -2
src/App.tsx
CHANGED
|
@@ -54,6 +54,15 @@ type ResearchTour = {
|
|
| 54 |
neighborhoodIds: string[];
|
| 55 |
};
|
| 56 |
type ResearchFact = NonNullable<NonNullable<AgentBackendRun["webResearch"]>["facts"]>[number];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
const initialParsed = parsePrompt(defaultPrompt);
|
| 59 |
const initialRanked = rankNeighborhoods(initialParsed);
|
|
@@ -87,6 +96,9 @@ const RESEARCH_TOUR_OVERVIEW_MS = 0;
|
|
| 87 |
// Must match TOUR_DWELL_MS in MapCanvas: the camera dwells on each area while it is researched.
|
| 88 |
const RESEARCH_TOUR_VISIT_MS = 5400;
|
| 89 |
const RESEARCH_TOUR_SETTLE_MS = 520;
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
// Quick-start chips above the composer. They are just shortcuts — the real entry point is
|
| 92 |
// whatever the user types in the box.
|
|
@@ -506,6 +518,7 @@ export default function App() {
|
|
| 506 |
/>
|
| 507 |
) : (
|
| 508 |
<DetailPanel
|
|
|
|
| 509 |
selected={selected}
|
| 510 |
parsed={parsed}
|
| 511 |
scoreT={scoreT}
|
|
@@ -701,12 +714,14 @@ function ResultRow({
|
|
| 701 |
}
|
| 702 |
|
| 703 |
function DetailPanel({
|
|
|
|
| 704 |
selected,
|
| 705 |
parsed,
|
| 706 |
scoreT,
|
| 707 |
webResearch,
|
| 708 |
recommendation,
|
| 709 |
}: {
|
|
|
|
| 710 |
selected: RankedNeighborhood;
|
| 711 |
parsed: ParsedPrompt;
|
| 712 |
scoreT: number;
|
|
@@ -714,57 +729,137 @@ function DetailPanel({
|
|
| 714 |
recommendation: AgentBackendRun["recommendation"] | null;
|
| 715 |
}) {
|
| 716 |
const fit = consensus(selected.overall);
|
| 717 |
-
const [audioState, setAudioState] = useState<
|
| 718 |
const audioRef = useRef<HTMLAudioElement | null>(null);
|
| 719 |
const reqRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
|
| 721 |
-
const
|
| 722 |
-
reqRef.current += 1; // invalidate any in-flight synthesis
|
| 723 |
if (audioRef.current) {
|
| 724 |
audioRef.current.pause();
|
| 725 |
-
if (audioRef.current.src) URL.revokeObjectURL(audioRef.current.src);
|
| 726 |
audioRef.current = null;
|
| 727 |
}
|
| 728 |
if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
|
| 729 |
-
setAudioState("idle");
|
| 730 |
}, []);
|
| 731 |
|
| 732 |
-
|
| 733 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
|
| 735 |
-
|
| 736 |
-
if (
|
| 737 |
-
stopSpeech();
|
| 738 |
return;
|
| 739 |
}
|
| 740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
const req = (reqRef.current += 1);
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
const element = new Audio(url);
|
| 750 |
audioRef.current = element;
|
| 751 |
element.onended = () => {
|
| 752 |
-
|
| 753 |
-
|
|
|
|
|
|
|
| 754 |
};
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
};
|
| 766 |
|
| 767 |
-
const
|
|
|
|
| 768 |
const safetyFact = findNeighborhoodFact(webResearch, selected.name, "safety");
|
| 769 |
const rentFact = findNeighborhoodFact(webResearch, selected.name, "rent");
|
| 770 |
const commuteFact = findNeighborhoodFact(webResearch, selected.name, "commute");
|
|
@@ -883,18 +978,21 @@ function DetailPanel({
|
|
| 883 |
</button>
|
| 884 |
<button
|
| 885 |
type="button"
|
| 886 |
-
className={`secondary-action ${playing ? "is-playing" : ""}`}
|
| 887 |
onClick={togglePlay}
|
| 888 |
-
|
|
|
|
|
|
|
|
|
|
| 889 |
>
|
| 890 |
-
{
|
| 891 |
<LoaderCircle size={14} className="spin" />
|
| 892 |
-
) :
|
| 893 |
<Square size={14} fill="currentColor" />
|
| 894 |
) : (
|
| 895 |
<Volume2 size={15} />
|
| 896 |
)}
|
| 897 |
-
{
|
| 898 |
</button>
|
| 899 |
</div>
|
| 900 |
</section>
|
|
@@ -1202,15 +1300,91 @@ function openListings(name: string) {
|
|
| 1202 |
window.open(`https://rentals.ca/toronto/${slug}`, "_blank", "noopener,noreferrer");
|
| 1203 |
}
|
| 1204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
// A short, spoken-friendly summary of the findings (~15-20s of narration), capped so it
|
| 1206 |
-
// never rambles. Leads with the match, then the model
|
|
|
|
| 1207 |
function buildSpokenSummary(
|
| 1208 |
selected: RankedNeighborhood,
|
| 1209 |
recommendation: AgentBackendRun["recommendation"] | null,
|
| 1210 |
webResearch: AgentBackendRun["webResearch"] | null,
|
| 1211 |
) {
|
| 1212 |
-
const parts = [`${selected.name}
|
| 1213 |
-
if (recommendation?.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1214 |
const commute = findNeighborhoodFact(webResearch, selected.name, "commute");
|
| 1215 |
if (commute && typeof commute.value === "number") {
|
| 1216 |
parts.push(`About ${commute.value} minutes to Union Station.`);
|
|
|
|
| 54 |
neighborhoodIds: string[];
|
| 55 |
};
|
| 56 |
type ResearchFact = NonNullable<NonNullable<AgentBackendRun["webResearch"]>["facts"]>[number];
|
| 57 |
+
type SummaryAudioState = "idle" | "generating" | "ready" | "playing";
|
| 58 |
+
type SummaryAudioCacheEntry = {
|
| 59 |
+
key: string;
|
| 60 |
+
text: string;
|
| 61 |
+
url: string | null;
|
| 62 |
+
status: "generating" | "ready" | "error";
|
| 63 |
+
promise: Promise<string | null> | null;
|
| 64 |
+
lastUsed: number;
|
| 65 |
+
};
|
| 66 |
|
| 67 |
const initialParsed = parsePrompt(defaultPrompt);
|
| 68 |
const initialRanked = rankNeighborhoods(initialParsed);
|
|
|
|
| 96 |
// Must match TOUR_DWELL_MS in MapCanvas: the camera dwells on each area while it is researched.
|
| 97 |
const RESEARCH_TOUR_VISIT_MS = 5400;
|
| 98 |
const RESEARCH_TOUR_SETTLE_MS = 520;
|
| 99 |
+
const SUMMARY_AUDIO_PRELOAD_LIMIT = 6;
|
| 100 |
+
const SUMMARY_AUDIO_CACHE_LIMIT = 10;
|
| 101 |
+
const summaryAudioCache = new Map<string, SummaryAudioCacheEntry>();
|
| 102 |
|
| 103 |
// Quick-start chips above the composer. They are just shortcuts — the real entry point is
|
| 104 |
// whatever the user types in the box.
|
|
|
|
| 518 |
/>
|
| 519 |
) : (
|
| 520 |
<DetailPanel
|
| 521 |
+
ranked={ranked}
|
| 522 |
selected={selected}
|
| 523 |
parsed={parsed}
|
| 524 |
scoreT={scoreT}
|
|
|
|
| 714 |
}
|
| 715 |
|
| 716 |
function DetailPanel({
|
| 717 |
+
ranked,
|
| 718 |
selected,
|
| 719 |
parsed,
|
| 720 |
scoreT,
|
| 721 |
webResearch,
|
| 722 |
recommendation,
|
| 723 |
}: {
|
| 724 |
+
ranked: RankedNeighborhood[];
|
| 725 |
selected: RankedNeighborhood;
|
| 726 |
parsed: ParsedPrompt;
|
| 727 |
scoreT: number;
|
|
|
|
| 729 |
recommendation: AgentBackendRun["recommendation"] | null;
|
| 730 |
}) {
|
| 731 |
const fit = consensus(selected.overall);
|
| 732 |
+
const [audioState, setAudioState] = useState<SummaryAudioState>("idle");
|
| 733 |
const audioRef = useRef<HTMLAudioElement | null>(null);
|
| 734 |
const reqRef = useRef(0);
|
| 735 |
+
const spokenText = useMemo(
|
| 736 |
+
() => buildSpokenSummary(selected, recommendation, webResearch),
|
| 737 |
+
[selected, recommendation, webResearch],
|
| 738 |
+
);
|
| 739 |
+
const audioKey = useMemo(() => summaryAudioKey(selected.id, spokenText), [selected.id, spokenText]);
|
| 740 |
+
const preloadAudioItems = useMemo(
|
| 741 |
+
() =>
|
| 742 |
+
ranked.slice(0, SUMMARY_AUDIO_PRELOAD_LIMIT).map((neighborhood) => {
|
| 743 |
+
const text = buildSpokenSummary(neighborhood, recommendation, webResearch);
|
| 744 |
+
return {
|
| 745 |
+
key: summaryAudioKey(neighborhood.id, text),
|
| 746 |
+
text,
|
| 747 |
+
};
|
| 748 |
+
}),
|
| 749 |
+
[ranked, recommendation, webResearch],
|
| 750 |
+
);
|
| 751 |
+
const shouldPrepareAudio = Boolean(recommendation?.summary);
|
| 752 |
|
| 753 |
+
const stopPlaybackOnly = useCallback(() => {
|
|
|
|
| 754 |
if (audioRef.current) {
|
| 755 |
audioRef.current.pause();
|
|
|
|
| 756 |
audioRef.current = null;
|
| 757 |
}
|
| 758 |
if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
|
|
|
|
| 759 |
}, []);
|
| 760 |
|
| 761 |
+
const stopPlayback = useCallback(() => {
|
| 762 |
+
if (audioRef.current) {
|
| 763 |
+
audioRef.current.pause();
|
| 764 |
+
audioRef.current.currentTime = 0;
|
| 765 |
+
audioRef.current = null;
|
| 766 |
+
}
|
| 767 |
+
if (typeof window !== "undefined" && window.speechSynthesis) window.speechSynthesis.cancel();
|
| 768 |
+
setAudioState(shouldPrepareAudio ? "ready" : "idle");
|
| 769 |
+
}, [shouldPrepareAudio]);
|
| 770 |
|
| 771 |
+
useEffect(() => {
|
| 772 |
+
if (!shouldPrepareAudio) {
|
|
|
|
| 773 |
return;
|
| 774 |
}
|
| 775 |
+
preloadAudioItems.forEach((item) => prepareSummaryAudio(item.key, item.text));
|
| 776 |
+
pruneSummaryAudioCache(new Set(preloadAudioItems.map((item) => item.key)));
|
| 777 |
+
}, [preloadAudioItems, shouldPrepareAudio]);
|
| 778 |
+
|
| 779 |
+
useEffect(() => {
|
| 780 |
const req = (reqRef.current += 1);
|
| 781 |
+
stopPlaybackOnly();
|
| 782 |
+
if (!shouldPrepareAudio) {
|
| 783 |
+
setAudioState("idle");
|
| 784 |
+
return () => {
|
| 785 |
+
if (reqRef.current === req) reqRef.current += 1;
|
| 786 |
+
stopPlaybackOnly();
|
| 787 |
+
};
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
const entry = prepareSummaryAudio(audioKey, spokenText);
|
| 791 |
+
setAudioState(entry.status === "ready" || entry.status === "error" ? "ready" : "generating");
|
| 792 |
+
if (entry.status === "generating") {
|
| 793 |
+
void entry.promise?.then(() => {
|
| 794 |
+
if (reqRef.current === req) setAudioState("ready");
|
| 795 |
+
});
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
return () => {
|
| 799 |
+
if (reqRef.current === req) reqRef.current += 1;
|
| 800 |
+
stopPlaybackOnly();
|
| 801 |
+
};
|
| 802 |
+
}, [audioKey, shouldPrepareAudio, spokenText, stopPlaybackOnly]);
|
| 803 |
+
|
| 804 |
+
const togglePlay = async () => {
|
| 805 |
+
if (audioState === "generating") return;
|
| 806 |
+
if (audioState === "playing") {
|
| 807 |
+
stopPlayback();
|
| 808 |
+
return;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
const playUrl = async (url: string, req: number) => {
|
| 812 |
const element = new Audio(url);
|
| 813 |
audioRef.current = element;
|
| 814 |
element.onended = () => {
|
| 815 |
+
if (reqRef.current === req) {
|
| 816 |
+
audioRef.current = null;
|
| 817 |
+
setAudioState("ready");
|
| 818 |
+
}
|
| 819 |
};
|
| 820 |
+
element.onerror = () => {
|
| 821 |
+
if (reqRef.current === req) {
|
| 822 |
+
audioRef.current = null;
|
| 823 |
+
setAudioState("ready");
|
| 824 |
+
}
|
| 825 |
+
};
|
| 826 |
+
try {
|
| 827 |
+
await element.play();
|
| 828 |
+
if (reqRef.current === req) setAudioState("playing");
|
| 829 |
+
} catch {
|
| 830 |
+
if (reqRef.current !== req) return;
|
| 831 |
+
audioRef.current = null;
|
| 832 |
+
speakWithBrowser(spokenText, () => {
|
| 833 |
+
if (reqRef.current === req) setAudioState("ready");
|
| 834 |
+
});
|
| 835 |
+
setAudioState("playing");
|
| 836 |
+
}
|
| 837 |
+
};
|
| 838 |
+
|
| 839 |
+
const cached = getSummaryAudio(audioKey);
|
| 840 |
+
if (cached?.url && cached.text === spokenText) {
|
| 841 |
+
await playUrl(cached.url, reqRef.current);
|
| 842 |
+
return;
|
| 843 |
}
|
| 844 |
+
|
| 845 |
+
const req = (reqRef.current += 1);
|
| 846 |
+
setAudioState("generating");
|
| 847 |
+
const entry = prepareSummaryAudio(audioKey, spokenText, { force: cached?.status === "error" });
|
| 848 |
+
const url = await entry.promise;
|
| 849 |
+
if (reqRef.current !== req) return;
|
| 850 |
+
if (url) {
|
| 851 |
+
await playUrl(url, req);
|
| 852 |
+
return;
|
| 853 |
+
}
|
| 854 |
+
// Kokoro unavailable (still downloading / unsupported) — fall back to the browser voice.
|
| 855 |
+
speakWithBrowser(spokenText, () => {
|
| 856 |
+
if (reqRef.current === req) setAudioState("ready");
|
| 857 |
+
});
|
| 858 |
+
setAudioState("playing");
|
| 859 |
};
|
| 860 |
|
| 861 |
+
const generating = audioState === "generating";
|
| 862 |
+
const playing = audioState === "playing";
|
| 863 |
const safetyFact = findNeighborhoodFact(webResearch, selected.name, "safety");
|
| 864 |
const rentFact = findNeighborhoodFact(webResearch, selected.name, "rent");
|
| 865 |
const commuteFact = findNeighborhoodFact(webResearch, selected.name, "commute");
|
|
|
|
| 978 |
</button>
|
| 979 |
<button
|
| 980 |
type="button"
|
| 981 |
+
className={`secondary-action ${playing ? "is-playing" : ""} ${generating ? "is-generating" : ""}`}
|
| 982 |
onClick={togglePlay}
|
| 983 |
+
disabled={generating}
|
| 984 |
+
aria-label={
|
| 985 |
+
generating ? "Summary audio is being generated" : playing ? "Stop summary audio" : "Play summary audio"
|
| 986 |
+
}
|
| 987 |
>
|
| 988 |
+
{generating ? (
|
| 989 |
<LoaderCircle size={14} className="spin" />
|
| 990 |
+
) : playing ? (
|
| 991 |
<Square size={14} fill="currentColor" />
|
| 992 |
) : (
|
| 993 |
<Volume2 size={15} />
|
| 994 |
)}
|
| 995 |
+
{generating ? "Generating audio" : playing ? "Stop" : "Play"}
|
| 996 |
</button>
|
| 997 |
</div>
|
| 998 |
</section>
|
|
|
|
| 1300 |
window.open(`https://rentals.ca/toronto/${slug}`, "_blank", "noopener,noreferrer");
|
| 1301 |
}
|
| 1302 |
|
| 1303 |
+
function summaryAudioKey(neighborhoodId: string, text: string) {
|
| 1304 |
+
return `${neighborhoodId}:${hashText(text)}`;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
function hashText(text: string) {
|
| 1308 |
+
let hash = 5381;
|
| 1309 |
+
for (let index = 0; index < text.length; index += 1) {
|
| 1310 |
+
hash = (hash * 33) ^ text.charCodeAt(index);
|
| 1311 |
+
}
|
| 1312 |
+
return (hash >>> 0).toString(36);
|
| 1313 |
+
}
|
| 1314 |
+
|
| 1315 |
+
function getSummaryAudio(key: string) {
|
| 1316 |
+
const entry = summaryAudioCache.get(key);
|
| 1317 |
+
if (entry) entry.lastUsed = Date.now();
|
| 1318 |
+
return entry;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
function prepareSummaryAudio(key: string, text: string, options: { force?: boolean } = {}) {
|
| 1322 |
+
const existing = summaryAudioCache.get(key);
|
| 1323 |
+
if (
|
| 1324 |
+
existing &&
|
| 1325 |
+
existing.text === text &&
|
| 1326 |
+
!options.force &&
|
| 1327 |
+
(existing.status === "ready" || existing.status === "generating")
|
| 1328 |
+
) {
|
| 1329 |
+
existing.lastUsed = Date.now();
|
| 1330 |
+
return existing;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
if (existing?.url) URL.revokeObjectURL(existing.url);
|
| 1334 |
+
|
| 1335 |
+
const entry: SummaryAudioCacheEntry = {
|
| 1336 |
+
key,
|
| 1337 |
+
text,
|
| 1338 |
+
url: null,
|
| 1339 |
+
status: "generating",
|
| 1340 |
+
promise: null,
|
| 1341 |
+
lastUsed: Date.now(),
|
| 1342 |
+
};
|
| 1343 |
+
entry.promise = synthesizeKokoro(text)
|
| 1344 |
+
.then((url) => {
|
| 1345 |
+
entry.url = url;
|
| 1346 |
+
entry.status = "ready";
|
| 1347 |
+
entry.lastUsed = Date.now();
|
| 1348 |
+
return url;
|
| 1349 |
+
})
|
| 1350 |
+
.catch(() => {
|
| 1351 |
+
entry.status = "error";
|
| 1352 |
+
entry.promise = null;
|
| 1353 |
+
entry.lastUsed = Date.now();
|
| 1354 |
+
return null;
|
| 1355 |
+
});
|
| 1356 |
+
summaryAudioCache.set(key, entry);
|
| 1357 |
+
pruneSummaryAudioCache();
|
| 1358 |
+
return entry;
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
function pruneSummaryAudioCache(keepKeys: Set<string> = new Set()) {
|
| 1362 |
+
if (summaryAudioCache.size <= SUMMARY_AUDIO_CACHE_LIMIT) return;
|
| 1363 |
+
const candidates = [...summaryAudioCache.values()]
|
| 1364 |
+
.filter((entry) => !keepKeys.has(entry.key))
|
| 1365 |
+
.sort((a, b) => a.lastUsed - b.lastUsed);
|
| 1366 |
+
while (summaryAudioCache.size > SUMMARY_AUDIO_CACHE_LIMIT && candidates.length) {
|
| 1367 |
+
const entry = candidates.shift();
|
| 1368 |
+
if (!entry) return;
|
| 1369 |
+
if (entry.url) URL.revokeObjectURL(entry.url);
|
| 1370 |
+
summaryAudioCache.delete(entry.key);
|
| 1371 |
+
}
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
// A short, spoken-friendly summary of the findings (~15-20s of narration), capped so it
|
| 1375 |
+
// never rambles. Leads with the selected match, then only uses the model recommendation
|
| 1376 |
+
// when it applies to that neighbourhood.
|
| 1377 |
function buildSpokenSummary(
|
| 1378 |
selected: RankedNeighborhood,
|
| 1379 |
recommendation: AgentBackendRun["recommendation"] | null,
|
| 1380 |
webResearch: AgentBackendRun["webResearch"] | null,
|
| 1381 |
) {
|
| 1382 |
+
const parts = [`${selected.name} scores ${selected.overall} out of 100 for this search.`];
|
| 1383 |
+
if (recommendation?.selectedId === selected.id && recommendation.summary) {
|
| 1384 |
+
parts.push(recommendation.summary);
|
| 1385 |
+
}
|
| 1386 |
+
const rent = findNeighborhoodFact(webResearch, selected.name, "rent");
|
| 1387 |
+
if (rent) parts.push(`${rent.label}: ${formatFactValue(rent)}.`);
|
| 1388 |
const commute = findNeighborhoodFact(webResearch, selected.name, "commute");
|
| 1389 |
if (commute && typeof commute.value === "number") {
|
| 1390 |
parts.push(`About ${commute.value} minutes to Union Station.`);
|
src/styles.css
CHANGED
|
@@ -255,6 +255,12 @@ textarea:focus-visible {
|
|
| 255 |
animation: speak-pulse 1.4s ease-in-out infinite;
|
| 256 |
}
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
@keyframes speak-pulse {
|
| 259 |
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 75, 120, 0.35); }
|
| 260 |
50% { box-shadow: 0 0 0 5px rgba(48, 75, 120, 0); }
|
|
@@ -1074,7 +1080,8 @@ textarea:focus-visible {
|
|
| 1074 |
|
| 1075 |
.detail-stack {
|
| 1076 |
display: flex;
|
| 1077 |
-
|
|
|
|
| 1078 |
flex-direction: column;
|
| 1079 |
gap: 12px;
|
| 1080 |
overflow-x: hidden;
|
|
@@ -1459,12 +1466,15 @@ textarea:focus-visible {
|
|
| 1459 |
height: 48px;
|
| 1460 |
margin: 0;
|
| 1461 |
padding: 0 12px;
|
|
|
|
|
|
|
| 1462 |
white-space: nowrap;
|
| 1463 |
}
|
| 1464 |
|
| 1465 |
.agent-rail {
|
| 1466 |
display: flex;
|
| 1467 |
-
|
|
|
|
| 1468 |
flex-direction: column;
|
| 1469 |
padding: 0;
|
| 1470 |
overflow: hidden;
|
|
|
|
| 255 |
animation: speak-pulse 1.4s ease-in-out infinite;
|
| 256 |
}
|
| 257 |
|
| 258 |
+
.secondary-action.is-generating,
|
| 259 |
+
.secondary-action:disabled {
|
| 260 |
+
cursor: default;
|
| 261 |
+
opacity: 0.78;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
@keyframes speak-pulse {
|
| 265 |
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 75, 120, 0.35); }
|
| 266 |
50% { box-shadow: 0 0 0 5px rgba(48, 75, 120, 0); }
|
|
|
|
| 1080 |
|
| 1081 |
.detail-stack {
|
| 1082 |
display: flex;
|
| 1083 |
+
/* Leave room at the bottom-right so the panel never covers the map controls. */
|
| 1084 |
+
max-height: calc(100vh - 196px);
|
| 1085 |
flex-direction: column;
|
| 1086 |
gap: 12px;
|
| 1087 |
overflow-x: hidden;
|
|
|
|
| 1466 |
height: 48px;
|
| 1467 |
margin: 0;
|
| 1468 |
padding: 0 12px;
|
| 1469 |
+
overflow: hidden;
|
| 1470 |
+
font-size: 12px;
|
| 1471 |
white-space: nowrap;
|
| 1472 |
}
|
| 1473 |
|
| 1474 |
.agent-rail {
|
| 1475 |
display: flex;
|
| 1476 |
+
/* Leave room at the bottom-right so the panel never covers the map controls. */
|
| 1477 |
+
max-height: calc(100vh - 196px);
|
| 1478 |
flex-direction: column;
|
| 1479 |
padding: 0;
|
| 1480 |
overflow: hidden;
|