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 491f3ad4..9ef39427 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 @@ -7,12 +7,11 @@ import { } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; import { - CheckCircleIcon, ChevronDownIcon, GlobeIcon, LoaderIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; interface WebSearchResultProps { @@ -26,6 +25,11 @@ interface WebSearchResultProps { // next one slides in. Kept slow enough to read the domain + title. const ROLL_INTERVAL_MS = 700; +// How many favicons to show in the settled stack before the rest collapse +// into a "+N" chip. The text names this many domains too, so the chip count +// (total - MAX_STACK) lines up with the "and N others" in the summary. +const MAX_STACK = 3; + function getDomain(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); @@ -34,8 +38,56 @@ function getDomain(url: string): string { } } -function faviconUrl(domain: string): string { - return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`; +function faviconUrl(domain: string, size = 32): string { + return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`; +} + +// Collapse the result list into unique domains, preserving order. +function uniqueDomains(results: WebSearchResultProps["results"]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const result of results) { + const domain = getDomain(result.url); + if (seen.has(domain)) continue; + seen.add(domain); + out.push(domain); + } + return out; +} + +// Summary with text hierarchy: "Searched" + "and N others" are secondary +// weight/color, the domain names are primary text at medium weight. +function buildSearchedSummary(domains: string[]): React.ReactNode { + const muted = "font-normal text-muted-foreground"; + const name = (d: string) => {d}; + if (domains.length === 1) { + return ( + <> + Searched + {name(domains[0])} + + ); + } + if (domains.length === 2) { + return ( + <> + Searched + {name(domains[0])} + and + {name(domains[1])} + + ); + } + const others = domains.length - 2; + return ( + <> + Searched + {name(domains[0])} + , + {name(domains[1])} + {` and ${others} other${others !== 1 ? "s" : ""}`} + + ); } type RollPhase = "searching" | "rolling" | "settled"; @@ -44,6 +96,8 @@ export function WebSearchResult({ query, results, status, title = "Searched the const isRunning = status === "pending" || status === "running"; const [open, setOpen] = useState(false); + const domains = useMemo(() => uniqueDomains(results), [results]); + // 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. @@ -89,7 +143,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the if (phase === "searching") { headerKey = "searching"; headerContent = ( - + Searching the web… @@ -99,7 +153,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the const domain = getDomain(result.url); headerKey = `roll-${rollIndex}`; headerContent = ( - + {domain} @@ -110,13 +164,34 @@ export function WebSearchResult({ query, results, status, title = "Searched the ); } else { headerKey = "settled"; + const stack = domains.slice(0, MAX_STACK); + // Chip count matches the "and N others" in the text (total minus the 2 + // named domains), shown only when there are sites beyond the stack. + const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0; headerContent = ( - - - - {results.length > 0 - ? `Found ${results.length} source${results.length !== 1 ? "s" : ""}` - : title} + + {domains.length > 0 ? ( + + {stack.map((domain, i) => ( + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ) : ( + + )} + + {domains.length > 0 ? buildSearchedSummary(domains) : title} ); @@ -126,11 +201,11 @@ export function WebSearchResult({ query, results, status, title = "Searched the - + {/* Rolling header: clipped, fixed height so sliding lines stay contained */} -
+
{headerContent}
-
- {phase === "settled" && !isRunning && ( - - - Done +
+ {phase === "settled" && domains.length > 0 && ( + + {domains.length} source{domains.length !== 1 ? "s" : ""} )}
-
- {/* Query + result count */} -
-
- - {query} -
- {results.length > 0 && ( - - {results.length} result{results.length !== 1 ? "s" : ""} - - )} +
+ {/* Query */} +
+ + {query}
{/* Results list */} @@ -203,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the
)} - {/* Status */} -
- {isRunning ? ( - <> - - Searching... - - ) : ( - <> - - Done - - )} -
+ {/* Status — only while the search is still running. */} + {isRunning && ( +
+ + Searching... +
+ )}