From b89b91258e4fdca66251e1222cc90784c81f96d5 Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 28 May 2026 01:57:46 +0530 Subject: [PATCH] feat: redesign web search & tool-call cards (rolling reveal, shared surface, action summaries) (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: roll web search sources in one-by-one with settle animation * fix: keep web search toggle on for the rest of the chat session * feat: redesign collapsed web search card with favicon stack and source summary * style: tune web search card surface tints for light and dark mode * feat: rounder web search card with subtle expand/collapse animation * feat: apply web search card design to tool-call box with action summary Shared --card-surface token, rounded card, hover, collapse animation, and a state-driven lead icon (spinner/check/cross). Single tools and the group now match. Completed group shows 'Ran N tools · , more...' with the action summary in lighter gray. * style: drop lead icon from tool group child rows and round them more --- apps/x/apps/renderer/src/App.css | 28 ++ .../src/components/ai-elements/tool.tsx | 101 ++++--- .../ai-elements/web-search-result.tsx | 249 +++++++++++++++--- .../components/chat-input-with-mentions.tsx | 3 +- .../renderer/src/lib/chat-conversation.ts | 57 ++++ 5 files changed, 343 insertions(+), 95 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 46763d5c..86c6535d 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,6 +35,30 @@ } } +/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) + plus a subtle fade. Used by the web search card. */ +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -1176,6 +1200,10 @@ --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + /* Subtle raised-card surface: tints toward foreground, so it reads a hair + darker than the background in light mode and a hair lighter in dark mode. + Shared by the web search card and tool-call group. */ + --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); --rowboat-panel: oklch(0.97 0 0); --rowboat-raised: oklch(1 0 0); --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 5f65fa32..61ba6fbd 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, @@ -9,17 +8,15 @@ import { import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { - CheckCircleIcon, ChevronDownIcon, - CircleIcon, - ClockIcon, - WrenchIcon, + CircleCheck, + LoaderIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; -import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; +import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -52,7 +49,10 @@ export type ToolProps = ComponentProps; export const Tool = ({ className, ...props }: ToolProps) => ( ); @@ -62,37 +62,17 @@ export type ToolHeaderProps = { type: ToolUIPart["type"]; state: ToolUIPart["state"]; className?: string; + /** Hide the leading status icon (used for child rows inside a tool group). */ + hideLeadIcon?: boolean; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { - const labels: Record = { - "input-streaming": "Pending", - "input-available": "Running", - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": "Awaiting Approval", - "approval-responded": "Responded", - "output-available": "Completed", - "output-error": "Error", - "output-denied": "Denied", - }; - - const icons: Record = { - "input-streaming": , - "input-available": , - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": , - "approval-responded": , - "output-available": , - "output-error": , - "output-denied": , - }; - - return ( - - {icons[status]} - {labels[status]} - - ); +// Lead icon shown to the left of the tool label: spinner while running, a +// green check when done, a red cross on error. Shared by ToolHeader (single +// tools) and the tool-call group. +const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => { + if (state === "output-available") return ; + if (state === "output-error") return ; + return ; }; export const ToolHeader = ({ @@ -100,6 +80,7 @@ export const ToolHeader = ({ title, type, state, + hideLeadIcon, ...props }: ToolHeaderProps) => { const displayTitle = title ?? type.split("-").slice(1).join("-") @@ -107,13 +88,13 @@ export const ToolHeader = ({ return (
- + {!hideLeadIcon && getLeadIcon(state)}
-
- {getStatusBadge(state)} - -
+
) }; @@ -134,7 +112,7 @@ export type ToolContentProps = ComponentProps; export const ToolContent = ({ className, ...props }: ToolContentProps) => ( t.status === 'running' || t.status === 'pending') const currentTool = runningTool ?? group.items[group.items.length - 1] - const summary = isCompleted - ? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}` + const toolCount = group.items.length + const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}` + const actions = isCompleted ? getToolActionsSummary(group.items) : '' + // Plain string used as the AnimatePresence key + tooltip; the rendered node + // shows the action summary in a lighter gray than the "Ran N tools" prefix. + const summaryText = isCompleted + ? `${ranLabel} · ${actions}` : currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items) + const summaryNode: ReactNode = isCompleted + ? <>{ranLabel} {`· ${actions}`} + : summaryText + + const leadIcon = getLeadIcon(state) return ( - +
- + {leadIcon}
- {summary} + {summaryNode}
-
- {getStatusBadge(state)} - -
+
- +
{group.items.map((tool) => { const toolState = toToolState(tool.status) @@ -291,12 +276,14 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool key={tool.id} open={isOpen} onOpenChange={(o) => onToolOpenChange(tool.id, o)} - className="mb-0 border-border/60" + className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60" > (); + 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"; + export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; + const [open, setOpen] = useState(false); - return ( - - -
- - {title} -
- -
- -
- {/* Query + result count */} -
-
- - {query} -
- {results.length > 0 && ( - - {results.length} result{results.length !== 1 ? "s" : ""} + 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. + // `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"; + 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 = ( + + {domains.length > 0 ? ( + + {stack.map((domain, i) => ( + + ))} + {overflow > 0 && ( + + +{overflow} )} + + ) : ( + + )} + + {domains.length > 0 ? buildSearchedSummary(domains) : title} + + + ); + } + + return ( + + + {/* Rolling header: clipped, fixed height so sliding lines stay contained */} +
+ + + {headerContent} + + +
+
+ {phase === "settled" && domains.length > 0 && ( + + {domains.length} source{domains.length !== 1 ? "s" : ""} + + )} + +
+
+ +
+ {/* Query */} +
+ + {query}
{/* Results list */} @@ -73,7 +255,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the >
@@ -88,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... +
+ )}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 013a68ad..d59b4047 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -382,7 +382,8 @@ function ChatInputInner({ controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) - setSearchEnabled(false) + // Web search toggle stays on for the rest of the chat session; the user + // turns it off explicitly. (Not persisted across app restarts.) }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 576997ad..41344107 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -653,6 +653,63 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => { return names.join(' · ') } +// Past-tense action phrases for summarizing a finished tool group, e.g. +// "read 3 files, listed directory". Keyed by builtin tool name. +const TOOL_ACTION_VERBS: Record = { + 'file-readText': { verb: 'read', one: 'file', many: 'files' }, + 'file-writeText': { verb: 'wrote', one: 'file', many: 'files' }, + 'file-editText': { verb: 'edited', one: 'file', many: 'files' }, + 'file-list': { verb: 'listed', one: 'directory', many: 'directories' }, + 'file-exists': { verb: 'checked', one: 'path', many: 'paths' }, + 'file-stat': { verb: 'inspected', one: 'file', many: 'files' }, + 'file-glob': { verb: 'searched for', one: 'file', many: 'files' }, + 'file-grep': { verb: 'searched', one: 'file', many: 'files' }, + 'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' }, + 'file-rename': { verb: 'renamed', one: 'file', many: 'files' }, + 'file-copy': { verb: 'copied', one: 'file', many: 'files' }, + 'file-remove': { verb: 'removed', one: 'file', many: 'files' }, + 'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' }, + 'executeCommand': { verb: 'ran', one: 'command', many: 'commands' }, + 'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' }, + 'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' }, + 'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' }, + 'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' }, + 'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' }, + 'parseFile': { verb: 'parsed', one: 'file', many: 'files' }, +} + +// Summarize what a group of tools actually did, grouping identical actions +// and counting them: "read 3 files, listed directory". Unmapped tools fall +// back to their lowercased display name. +export const getToolActionsSummary = (tools: ToolCall[]): string => { + const order: string[] = [] + const grouped = new Map() + for (const tool of tools) { + const phrase = TOOL_ACTION_VERBS[tool.name] ?? null + const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name + const existing = grouped.get(key) + if (existing) { + existing.count++ + } else { + grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) }) + order.push(key) + } + } + const phrases = order.map((key) => { + const { phrase, count, fallback } = grouped.get(key)! + if (!phrase) return fallback.toLowerCase() + if (count > 1) return `${phrase.verb} ${count} ${phrase.many}` + const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a' + return `${phrase.verb} ${article} ${phrase.one}` + }) + // Show at most two operations; collapse the rest into "more...". + const MAX_ACTIONS = 2 + if (phrases.length > MAX_ACTIONS) { + return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...` + } + return phrases.join(', ') +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim()