mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
feat: redesign web search & tool-call cards (rolling reveal, shared surface, action summaries) (#579)
* 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 · <up to 2 actions>, more...' with the action summary in lighter gray. * style: drop lead icon from tool group child rows and round them more
This commit is contained in:
parent
daff21481a
commit
b89b91258e
5 changed files with 343 additions and 95 deletions
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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<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 = ({
|
||||
|
|
@ -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 (
|
||||
<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" />
|
||||
{!hideLeadIcon && getLeadIcon(state)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||
title={displayTitle}
|
||||
|
|
@ -121,10 +102,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 +112,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 +225,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 +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"
|
||||
>
|
||||
<ToolHeader
|
||||
title={getToolDisplayName(tool)}
|
||||
type={`tool-${tool.name}`}
|
||||
state={toolState}
|
||||
className="text-muted-foreground"
|
||||
hideLeadIcon
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
interface WebSearchResultProps {
|
||||
query: string;
|
||||
|
|
@ -19,39 +21,219 @@ 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;
|
||||
|
||||
// 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;
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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) => <span className="font-medium text-foreground">{d}</span>;
|
||||
if (domains.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (domains.length === 2) {
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}> and </span>
|
||||
{name(domains[1])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const others = domains.length - 2;
|
||||
return (
|
||||
<>
|
||||
<span className={muted}>Searched </span>
|
||||
{name(domains[0])}
|
||||
<span className={muted}>, </span>
|
||||
{name(domains[1])}
|
||||
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Collapsible defaultOpen 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>
|
||||
</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">
|
||||
{/* Query + result count */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{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 = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin" />
|
||||
<span className="truncate">Searching the web…</span>
|
||||
</span>
|
||||
);
|
||||
} else if (phase === "rolling") {
|
||||
const result = results[rollIndex];
|
||||
const domain = getDomain(result.url);
|
||||
headerKey = `roll-${rollIndex}`;
|
||||
headerContent = (
|
||||
<span className="flex min-w-0 flex-1 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"> · </span>
|
||||
<span>{result.title}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} 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 = (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{domains.length > 0 ? (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{stack.map((domain, i) => (
|
||||
<img
|
||||
key={domain}
|
||||
src={faviconUrl(domain)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
|
||||
style={{ zIndex: stack.length - i }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{domains.length > 0 ? buildSearchedSummary(domains) : title}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
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 */}
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
|
||||
<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 flex items-center text-left font-medium text-sm"
|
||||
>
|
||||
{headerContent}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{phase === "settled" && domains.length > 0 && (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{domains.length} source{domains.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<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="px-4 pb-3 space-y-3">
|
||||
{/* Query */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{query}</span>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
|
|
@ -73,7 +255,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"
|
||||
/>
|
||||
|
|
@ -88,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
<span>Done</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Status — only while the search is still running. */}
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue