diff --git a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx index 30e5c002..491f3ad4 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx @@ -5,12 +5,15 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; import { CheckCircleIcon, ChevronDownIcon, GlobeIcon, LoaderIcon, } from "lucide-react"; +import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; interface WebSearchResultProps { query: string; @@ -19,25 +22,137 @@ interface WebSearchResultProps { title?: string; } +// How long each fetched website stays on the rolling header before the +// next one slides in. Kept slow enough to read the domain + title. +const ROLL_INTERVAL_MS = 700; + function getDomain(url: string): string { try { - return new URL(url).hostname; + return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } } +function faviconUrl(domain: string): string { + return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`; +} + +type RollPhase = "searching" | "rolling" | "settled"; + export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; + const [open, setOpen] = useState(false); + + // Drive the one-shot rolling reveal. Results arrive all at once, so we + // simulate "fetching one site at a time" by stepping through them with the + // same slide animation the tool group uses, then settle on a summary. + // `settled` is seeded from the initial status so a card loaded already- + // complete from history skips straight to the summary (no roll). + const [settled, setSettled] = useState(() => !isRunning); + const [rollIndex, setRollIndex] = useState(0); + + // Phase is fully derived: searching while the tool runs, rolling once + // results land, then settled. No setState-in-effect needed for transitions. + const phase: RollPhase = isRunning + ? "searching" + : !settled && results.length > 0 + ? "rolling" + : "settled"; + + // Warm the browser cache for every favicon the moment results arrive, so + // each icon is already loaded by the time its row rolls in (~700ms each). + // Without this the network fetch lags the text and rows flash icon-less. + useEffect(() => { + for (const result of results) { + const img = new Image(); + img.src = faviconUrl(getDomain(result.url)); + } + }, [results]); + + // Advance the roll, then settle after the last site has had its moment. + // setState only fires inside the timeout callback, never synchronously. + useEffect(() => { + if (phase !== "rolling") return; + const isLast = rollIndex >= results.length - 1; + const timer = setTimeout( + () => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)), + ROLL_INTERVAL_MS, + ); + return () => clearTimeout(timer); + }, [phase, rollIndex, results.length]); + + // Build the content for the compact (collapsed) header line. Each distinct + // value gets a unique key so AnimatePresence runs the slide transition. + let headerKey: string; + let headerContent: React.ReactNode; + if (phase === "searching") { + headerKey = "searching"; + headerContent = ( + + + Searching the web… + + ); + } else if (phase === "rolling") { + const result = results[rollIndex]; + const domain = getDomain(result.url); + headerKey = `roll-${rollIndex}`; + headerContent = ( + + + + {domain} + · + {result.title} + + + ); + } else { + headerKey = "settled"; + headerContent = ( + + + + {results.length > 0 + ? `Found ${results.length} source${results.length !== 1 ? "s" : ""}` + : title} + + + ); + } return ( - + -
- - {title} + {/* Rolling header: clipped, fixed height so sliding lines stay contained */} +
+ + + {headerContent} + + +
+
+ {phase === "settled" && !isRunning && ( + + + Done + + )} +
-
@@ -73,7 +188,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the >