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 */}
+