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 · <up to 2 actions>, more...' with the action summary in lighter gray.
This commit is contained in:
Gagancreates 2026-05-28 01:48:33 +05:30
parent dcd9a40bef
commit 0fa89fc108
4 changed files with 102 additions and 58 deletions

View file

@ -1200,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%);

View file

@ -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<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)}
className={cn(
"not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
{...props}
/>
);
@ -64,35 +64,13 @@ export type ToolHeaderProps = {
className?: string;
};
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = {
"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<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
// @ts-expect-error state only available in AI SDK v6
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
// 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 <CircleCheck className="size-4 shrink-0 text-green-600" />;
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
};
export const ToolHeader = ({
@ -107,13 +85,13 @@ export const ToolHeader = ({
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
className
)}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
{getLeadIcon(state)}
<span
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
title={displayTitle}
@ -121,10 +99,7 @@ export const ToolHeader = ({
{displayTitle}
</span>
</div>
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
)
};
@ -134,7 +109,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
className
)}
{...props}
@ -247,41 +222,48 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
const isCompleted = state === 'output-available' || state === 'output-error'
const runningTool = group.items.find(t => 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} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></>
: summaryText
const leadIcon = getLeadIcon(state)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-md border"
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
{leadIcon}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={summary}
key={summaryText}
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"
title={summary}
title={summaryText}
>
{summary}
{summaryNode}
</motion.span>
</AnimatePresence>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</div>
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
<div className="flex flex-col gap-2 p-2">
{group.items.map((tool) => {
const toolState = toToolState(tool.status)
@ -291,12 +273,13 @@ 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-md border-border/60 bg-transparent hover:border-border/60"
>
<ToolHeader
title={getToolDisplayName(tool)}
type={`tool-${tool.name}`}
state={toolState}
className="text-muted-foreground"
/>
<ToolContent>
<ToolTabbedContent

View file

@ -201,7 +201,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--ws-surface)] [--ws-surface:color-mix(in_oklab,var(--background)_98.5%,var(--foreground))] dark:[--ws-surface:color-mix(in_oklab,var(--background)_98.5%,var(--foreground))] transition-colors duration-150 ease-out hover:border-foreground/30"
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}

View file

@ -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<string, { verb: string; one: string; many: string }> = {
'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<string, { phrase: typeof TOOL_ACTION_VERBS[string] | null; count: number; fallback: string }>()
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()