feat: roll web search sources in one-by-one with settle animation

This commit is contained in:
Gagancreates 2026-05-27 19:00:08 +05:30
parent d981fa9206
commit e61f8532bc

View file

@ -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 = (
<span className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 shrink-0 animate-spin" />
<span className="truncate">Searching the web&hellip;</span>
</span>
);
} else if (phase === "rolling") {
const result = results[rollIndex];
const domain = getDomain(result.url);
headerKey = `roll-${rollIndex}`;
headerContent = (
<span className="flex items-center gap-2">
<img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" />
<span className="truncate">
<span className="text-muted-foreground">{domain}</span>
<span className="text-muted-foreground/50"> &middot; </span>
<span>{result.title}</span>
</span>
</span>
);
} else {
headerKey = "settled";
headerContent = (
<span className="flex items-center gap-2">
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{results.length > 0
? `Found ${results.length} source${results.length !== 1 ? "s" : ""}`
: title}
</span>
</span>
);
}
return (
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-md border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.25rem" }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={headerKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
>
{headerContent}
</motion.span>
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-3">
{phase === "settled" && !isRunning && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CheckCircleIcon className="size-3.5 text-green-600" />
Done
</span>
)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-3">
@ -73,7 +188,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
src={faviconUrl(domain)}
alt=""
className="size-4 shrink-0"
/>