Tool call row
One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
Install#
npx shadcn@latest add @pixilui/tool-call-rowOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/tool-call-row.json
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { Children, forwardRef, isValidElement, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactElement, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; import { Button } from "./button"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type ToolCallRowStatus = "running" | "done" | "error"; /** A JSON value: what tools take and return. Strings render as text, everything else as formatted JSON. */ export type ToolCallRowValue = string | number | boolean | null | ToolCallRowValue[] | { [key: string]: ToolCallRowValue }; export type ToolCallRowLabels = { input: string; output: string; copy: string; copied: string; retry: string; retrying: string; failed: string; running: string; }; const defaultLabels: ToolCallRowLabels = { input: "Input", output: "Output", copy: "Copy", copied: "Copied", retry: "Retry", retrying: "Retrying", failed: "Failed", running: "Running", }; export type ToolCallRowProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & { /** The tool, as its code name: "zendesk.search_tickets". Set in mono. */ name: string; /** What the call did, in plain words and past tense once done: "Searched 14 tickets". */ summary?: ReactNode; /** Running shows a thin indeterminate bar; error shows the message and Retry. */ status: ToolCallRowStatus; /** The tool's mark, shown in a small tile. */ icon?: ReactNode; /** How long the call took (ms). Shown once it finishes. */ durationMs?: number; /** When the call started (epoch ms). Drives a live timer while running. */ startedAt?: number; /** What the agent sent. Objects are formatted as JSON. */ input?: ToolCallRowValue; /** What the tool returned. Objects are formatted as JSON. */ output?: ToolCallRowValue; /** Why it failed, in a sentence. Shown under the summary on error. */ error?: ReactNode; /** Controlled: whether the input and output are unfolded. */ expanded?: boolean; /** Starting fold when uncontrolled. */ defaultExpanded?: boolean; /** Called when the person folds or unfolds the row. */ onExpandedChange?: (expanded: boolean) => void; /** Shows Retry on error. Return a promise and the button shows its own pending state. */ onRetry?: () => void | Promise<void>; /** Copy overrides, for translation. */ labels?: Partial<ToolCallRowLabels>; }; /** One pass of the indeterminate bar: 1.44s, linear. */ const SWEEP = motionTokens.duration.considered * 3; const TICK_MS = 250; const COPIED_MS = 1600; /** "820ms", "1.4s", "1m 04s". */ function formatDuration(ms: number) { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } function formatLive(ms: number) { const s = Math.floor(ms / 1000); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } function useElapsed(startedAt: number | undefined, running: boolean) { const [now, setNow] = useState<number | null>(null); useEffect(() => { if (startedAt === undefined || !running) return; const tick = () => setNow(Date.now()); tick(); const id = setInterval(tick, TICK_MS); return () => clearInterval(id); }, [startedAt, running]); return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt); } /** Digits keyed by place that roll up as the time grows. */ function Ticker({ value, reduced }: { value: string; reduced: boolean }) { const chars = value.split(""); return ( <span role="timer" aria-label={value} className="numeric inline-flex"> {chars.map((char, index) => { const place = chars.length - index; if (reduced || !/\d/.test(char)) { return ( <span key={place} aria-hidden="true"> {char} </span> ); } return ( <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}> {char} </motion.span> </AnimatePresence> </span> ); })} </span> ); } /** A thin bar with a segment sliding across on linear easing. Still under reduced motion. */ function IndeterminateBar({ reduced, className }: { reduced: boolean; className?: string }) { return ( <span aria-hidden="true" data-slot="tool-call-row-progress" className={cn("pointer-events-none absolute h-0.5 overflow-hidden rounded-pill bg-border-subtle", className)}> {reduced ? null : ( <motion.span className="absolute inset-y-0 left-0 w-1/3 rounded-pill bg-foreground/45" initial={{ x: "-100%" }} animate={{ x: "300%" }} transition={{ duration: SWEEP, ease: motionTokens.ease.linear, repeat: Infinity }} /> )} </span> ); } function Chevron({ open }: { open: boolean }) { return ( <svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className={cn("size-3 shrink-0 text-muted transition-transform duration-fast ease-swap motion-reduce:transition-none", open && "rotate-90")} > <path d="M4.5 3l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> ); } const JSON_TOKEN = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g; /** Formatted JSON with keys a step quieter than values. Plain strings stay as written. */ function Payload({ value }: { value: ToolCallRowValue }) { if (typeof value === "string") return <>{value}</>; const text = JSON.stringify(value, null, 2); const parts: ReactNode[] = []; let last = 0; for (const match of text.matchAll(JSON_TOKEN)) { const index = match.index!; if (index > last) parts.push(text.slice(last, index)); if (match[2] !== undefined) { parts.push( <span key={index} className="text-secondary"> {match[1]} </span>, match[2], ); } else { parts.push( <span key={index} className="text-foreground"> {match[0]} </span>, ); } last = index + match[0].length; } parts.push(text.slice(last)); return <>{parts}</>; } function payloadText(value: ToolCallRowValue) { return typeof value === "string" ? value : JSON.stringify(value, null, 2); } function PayloadBlock({ label, value, labels, reduced }: { label: string; value: ToolCallRowValue; labels: ToolCallRowLabels; reduced: boolean }) { const [copied, setCopied] = useState(false); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); const copy = async () => { try { await navigator.clipboard.writeText(payloadText(value)); setCopied(true); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setCopied(false), COPIED_MS); } catch { // Clipboard blocked (insecure context, denied permission): nothing to undo. } }; const swap = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }, }; return ( <div className="flex min-w-0 flex-col gap-1.5"> <div className="flex items-center justify-between gap-2"> <span className="text-12 text-muted">{label}</span> <button type="button" onClick={() => void copy()} aria-label={copied ? `${label} ${labels.copied.toLowerCase()}` : `${labels.copy} ${label.toLowerCase()}`} className="relative inline-flex h-6 items-center rounded-pill px-2 text-12 text-muted outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted hover-fine:text-foreground focus-visible:focus-ring" > <AnimatePresence initial={false} mode="popLayout"> <motion.span key={copied ? "copied" : "copy"} className="inline-flex items-center gap-1" {...swap}> {copied ? ( <svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className="size-3"> <path d="M2.5 6.25l2.25 2.25 4.75-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> ) : null} {copied ? labels.copied : labels.copy} </motion.span> </AnimatePresence> </button> </div> <pre tabIndex={0} aria-label={label} className="max-h-48 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-control bg-surface-muted px-3.5 py-3 font-mono text-12 text-muted outline-none focus-visible:focus-ring" > <Payload value={value} /> </pre> </div> ); } export const ToolCallRow = forwardRef<HTMLDivElement, ToolCallRowProps>(function ToolCallRow( { name, summary, status, icon, durationMs, startedAt, input, output, error, expanded: expandedProp, defaultExpanded = false, onExpandedChange, onRetry, labels: labelsProp, className, ...rest }, forwardedRef, ) { const labels = { ...defaultLabels, ...labelsProp }; const reduced = useReducedMotionAfterHydration(); const foldId = useId(); const [innerExpanded, setInnerExpanded] = useState(defaultExpanded); const [retrying, setRetrying] = useState(false); const expandable = input !== undefined || output !== undefined; const open = (expandedProp ?? innerExpanded) && expandable; const live = useElapsed(startedAt, status === "running"); const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${status}`); const setRef = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef, containerRef], ); const toggle = () => { const next = !open; if (expandedProp === undefined) setInnerExpanded(next); onExpandedChange?.(next); }; const retry = async () => { if (!onRetry || retrying) return; setRetrying(true); try { await onRetry(); } finally { setRetrying(false); } }; const time = status === "running" ? ( live !== null ? ( <Ticker value={formatLive(live)} reduced={reduced} /> ) : null ) : status === "done" && durationMs !== undefined ? ( <span className="numeric">{formatDuration(durationMs)}</span> ) : null; const headerContent = ( <> <span className="grid size-7 shrink-0 place-items-center rounded-control bg-surface-muted text-foreground [&_svg]:size-3.5">{icon}</span> <span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 items-baseline gap-2"> <span className="min-w-0 truncate text-foreground">{summary ?? name}</span> {summary ? <span className="min-w-0 shrink-[2] truncate font-mono text-12 text-muted max-sm:hidden">{name}</span> : null} </span> {status === "error" && error ? <span className="min-w-0 truncate text-12 text-danger">{error}</span> : null} </span> <span className="flex shrink-0 items-center gap-2 text-12 text-muted"> {status === "error" ? <span className="text-danger">{labels.failed}</span> : null} {status === "running" ? <span className="sr-only">{labels.running}</span> : null} {time} </span> {expandable ? <Chevron open={open} /> : null} </> ); const headerClass = "relative flex min-w-0 flex-1 items-center gap-3 rounded-control px-2 py-1.5 text-left"; return ( <div ref={setRef} data-status={status} data-expanded={open} aria-busy={status === "running"} className={cn("relative w-full min-w-0 overflow-hidden font-body text-14 text-foreground", className)} {...rest} > <div ref={measureRef}> <div className="relative flex min-w-0 items-center gap-1"> {expandable ? ( <button type="button" data-slot="tool-call-row-trigger" aria-expanded={open} aria-controls={foldId} onClick={toggle} className={cn( headerClass, "outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus", )} > {headerContent} </button> ) : ( <div className={headerClass}>{headerContent}</div> )} {status === "error" && onRetry ? ( <Button variant="secondary" size="sm" loading={retrying} onClick={() => void retry()} className="shrink-0"> {retrying ? labels.retrying : labels.retry} </Button> ) : null} {status === "running" ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null} </div> <AnimatePresence initial={false} mode="popLayout"> {open ? ( <motion.div key="fold" id={foldId} data-slot="tool-call-row-fold" className="flex flex-col gap-3 pt-1.5 pe-2 pb-3 ps-12" initial={reduced ? { opacity: 0 } : { opacity: 0, y: motionTokens.distance.micro, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }} transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item }} > {input !== undefined ? <PayloadBlock label={labels.input} value={input} labels={labels} reduced={reduced} /> : null} {output !== undefined ? <PayloadBlock label={labels.output} value={output} labels={labels} reduced={reduced} /> : null} </motion.div> ) : null} </AnimatePresence> </div> </div> ); }); export type ToolCallRowGroupCounts = { total: number; running: number; done: number; error: number; /** Sum of the finished calls' durations (ms). */ durationMs: number; }; export type ToolCallRowGroupProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & { /** ToolCallRow elements. The group reads their status, duration and icon to write its summary. */ children: ReactNode; /** The summary line. A function receives the counts; the default reads "Used 4 tools" or "2 of 4 tools done". */ summary?: ReactNode | ((counts: ToolCallRowGroupCounts) => ReactNode); /** Controlled: whether the rows are unfolded. */ expanded?: boolean; /** Starting fold when uncontrolled. */ defaultExpanded?: boolean; /** Called when the person folds or unfolds the group. */ onExpandedChange?: (expanded: boolean) => void; }; const defaultSummary = (c: ToolCallRowGroupCounts) => { const noun = c.total === 1 ? "tool" : "tools"; if (c.running > 0) return `${c.done + c.error} of ${c.total} ${noun} done`; if (c.error > 0) return `Used ${c.total} ${noun}, ${c.error} failed`; return `Used ${c.total} ${noun}`; }; export const ToolCallRowGroup = forwardRef<HTMLDivElement, ToolCallRowGroupProps>(function ToolCallRowGroup( { children, summary = defaultSummary, expanded: expandedProp, defaultExpanded = false, onExpandedChange, className, ...rest }, forwardedRef, ) { const reduced = useReducedMotionAfterHydration(); const listId = useId(); const [innerExpanded, setInnerExpanded] = useState(defaultExpanded); const open = expandedProp ?? innerExpanded; const rows = Children.toArray(children).filter(isValidElement) as ReactElement<Partial<ToolCallRowProps>>[]; const counts: ToolCallRowGroupCounts = { total: rows.length, running: 0, done: 0, error: 0, durationMs: 0 }; for (const row of rows) { const s = row.props.status; if (s === "running") counts.running++; else if (s === "error") counts.error++; else if (s === "done") counts.done++; if (s !== "running" && typeof row.props.durationMs === "number") counts.durationMs += row.props.durationMs; } const icons = rows.map((row) => row.props.icon).filter(Boolean).slice(0, 3); const status: ToolCallRowStatus = counts.running > 0 ? "running" : counts.error > 0 ? "error" : "done"; const line = typeof summary === "function" ? summary(counts) : summary; const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${rows.length}`); const setRef = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef, containerRef], ); const toggle = () => { if (expandedProp === undefined) setInnerExpanded(!open); onExpandedChange?.(!open); }; // Up and Down walk the row triggers, Home and End jump to the ends. const handleListKeys = (event: KeyboardEvent<HTMLDivElement>) => { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="tool-call-row-trigger"]')); const current = triggers.indexOf(event.target as HTMLElement); if (current === -1) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? triggers.length - 1 : Math.min(triggers.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1))); triggers[next]?.focus(); }; const enter = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: reducedTransition } : { initial: { opacity: 0, y: motionTokens.distance.small, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }, }; return ( <div ref={setRef} data-status={status} data-expanded={open} aria-busy={counts.running > 0} className={cn("relative w-full min-w-0 overflow-hidden rounded-panel bg-surface font-body text-14 text-foreground shadow-resting hairline", className)} {...rest} > <div ref={measureRef} className="p-1.5"> <button type="button" aria-expanded={open} aria-controls={listId} onClick={toggle} className="relative flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-1.5 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus" > <span className="flex shrink-0 items-center"> {icons.length > 0 ? ( icons.map((icon, index) => ( <span key={index} className={cn( "grid size-7 place-items-center rounded-control bg-surface-muted text-foreground ring-2 ring-surface [&_svg]:size-3.5", index > 0 && "-ms-2", )} > {icon} </span> )) ) : ( <span className="size-7" /> )} </span> <span className="min-w-0 flex-1 truncate text-foreground">{line}</span> {counts.running === 0 && counts.durationMs > 0 ? <span className="numeric shrink-0 text-12 text-muted">{formatDuration(counts.durationMs)}</span> : null} <Chevron open={open} /> {counts.running > 0 && !open ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null} </button> <AnimatePresence initial={false} mode="popLayout"> {open ? ( <motion.div key="rows" id={listId} role="group" aria-label={typeof line === "string" ? line : undefined} className="mt-1 flex flex-col border-t border-border-subtle pt-1" onKeyDown={handleListKeys} initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={reduced ? { opacity: 1 } : { opacity: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }} transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }} > <AnimatePresence initial={false}> {rows.map((row, index) => ( <motion.div key={row.key ?? index} {...enter}> {row} </motion.div> ))} </AnimatePresence> </motion.div> ) : null} </AnimatePresence> </div> </div> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/tool-call-row.tsx. Needs motion, clsx, tailwind-merge.
Preview#
Calls land one by one under a single summary line that counts them, each with a sliding bar while it runs and rolling digits on its timer.
Code#
"use client";
import {
Children,
forwardRef,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactElement,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { useMorphSize } from "./lib/use-morph-size";
import { motionTokens, reducedTransition } from "./motion-tokens";
import { Button } from "./button";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
export type ToolCallRowStatus = "running" | "done" | "error";
/** A JSON value: what tools take and return. Strings render as text, everything else as formatted JSON. */
export type ToolCallRowValue = string | number | boolean | null | ToolCallRowValue[] | { [key: string]: ToolCallRowValue };
export type ToolCallRowLabels = {
input: string;
output: string;
copy: string;
copied: string;
retry: string;
retrying: string;
failed: string;
running: string;
};
const defaultLabels: ToolCallRowLabels = {
input: "Input",
output: "Output",
copy: "Copy",
copied: "Copied",
retry: "Retry",
retrying: "Retrying",
failed: "Failed",
running: "Running",
};
export type ToolCallRowProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
/** The tool, as its code name: "zendesk.search_tickets". Set in mono. */
name: string;
/** What the call did, in plain words and past tense once done: "Searched 14 tickets". */
summary?: ReactNode;
/** Running shows a thin indeterminate bar; error shows the message and Retry. */
status: ToolCallRowStatus;
/** The tool's mark, shown in a small tile. */
icon?: ReactNode;
/** How long the call took (ms). Shown once it finishes. */
durationMs?: number;
/** When the call started (epoch ms). Drives a live timer while running. */
startedAt?: number;
/** What the agent sent. Objects are formatted as JSON. */
input?: ToolCallRowValue;
/** What the tool returned. Objects are formatted as JSON. */
output?: ToolCallRowValue;
/** Why it failed, in a sentence. Shown under the summary on error. */
error?: ReactNode;
/** Controlled: whether the input and output are unfolded. */
expanded?: boolean;
/** Starting fold when uncontrolled. */
defaultExpanded?: boolean;
/** Called when the person folds or unfolds the row. */
onExpandedChange?: (expanded: boolean) => void;
/** Shows Retry on error. Return a promise and the button shows its own pending state. */
onRetry?: () => void | Promise<void>;
/** Copy overrides, for translation. */
labels?: Partial<ToolCallRowLabels>;
};
/** One pass of the indeterminate bar: 1.44s, linear. */
const SWEEP = motionTokens.duration.considered * 3;
const TICK_MS = 250;
const COPIED_MS = 1600;
/** "820ms", "1.4s", "1m 04s". */
function formatDuration(ms: number) {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const s = Math.round(ms / 1000);
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
function formatLive(ms: number) {
const s = Math.floor(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
function useElapsed(startedAt: number | undefined, running: boolean) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (startedAt === undefined || !running) return;
const tick = () => setNow(Date.now());
tick();
const id = setInterval(tick, TICK_MS);
return () => clearInterval(id);
}, [startedAt, running]);
return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt);
}
/** Digits keyed by place that roll up as the time grows. */
function Ticker({ value, reduced }: { value: string; reduced: boolean }) {
const chars = value.split("");
return (
<span role="timer" aria-label={value} className="numeric inline-flex">
{chars.map((char, index) => {
const place = chars.length - index;
if (reduced || !/\d/.test(char)) {
return (
<span key={place} aria-hidden="true">
{char}
</span>
);
}
return (
<span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}>
{char}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
);
}
/** A thin bar with a segment sliding across on linear easing. Still under reduced motion. */
function IndeterminateBar({ reduced, className }: { reduced: boolean; className?: string }) {
return (
<span aria-hidden="true" data-slot="tool-call-row-progress" className={cn("pointer-events-none absolute h-0.5 overflow-hidden rounded-pill bg-border-subtle", className)}>
{reduced ? null : (
<motion.span
className="absolute inset-y-0 left-0 w-1/3 rounded-pill bg-foreground/45"
initial={{ x: "-100%" }}
animate={{ x: "300%" }}
transition={{ duration: SWEEP, ease: motionTokens.ease.linear, repeat: Infinity }}
/>
)}
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
className={cn("size-3 shrink-0 text-muted transition-transform duration-fast ease-swap motion-reduce:transition-none", open && "rotate-90")}
>
<path d="M4.5 3l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
const JSON_TOKEN = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
/** Formatted JSON with keys a step quieter than values. Plain strings stay as written. */
function Payload({ value }: { value: ToolCallRowValue }) {
if (typeof value === "string") return <>{value}</>;
const text = JSON.stringify(value, null, 2);
const parts: ReactNode[] = [];
let last = 0;
for (const match of text.matchAll(JSON_TOKEN)) {
const index = match.index!;
if (index > last) parts.push(text.slice(last, index));
if (match[2] !== undefined) {
parts.push(
<span key={index} className="text-secondary">
{match[1]}
</span>,
match[2],
);
} else {
parts.push(
<span key={index} className="text-foreground">
{match[0]}
</span>,
);
}
last = index + match[0].length;
}
parts.push(text.slice(last));
return <>{parts}</>;
}
function payloadText(value: ToolCallRowValue) {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function PayloadBlock({ label, value, labels, reduced }: { label: string; value: ToolCallRowValue; labels: ToolCallRowLabels; reduced: boolean }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
const copy = async () => {
try {
await navigator.clipboard.writeText(payloadText(value));
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), COPIED_MS);
} catch {
// Clipboard blocked (insecure context, denied permission): nothing to undo.
}
};
const swap = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
: {
initial: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
animate: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap },
};
return (
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-12 text-muted">{label}</span>
<button
type="button"
onClick={() => void copy()}
aria-label={copied ? `${label} ${labels.copied.toLowerCase()}` : `${labels.copy} ${label.toLowerCase()}`}
className="relative inline-flex h-6 items-center rounded-pill px-2 text-12 text-muted outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted hover-fine:text-foreground focus-visible:focus-ring"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={copied ? "copied" : "copy"} className="inline-flex items-center gap-1" {...swap}>
{copied ? (
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className="size-3">
<path d="M2.5 6.25l2.25 2.25 4.75-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
{copied ? labels.copied : labels.copy}
</motion.span>
</AnimatePresence>
</button>
</div>
<pre
tabIndex={0}
aria-label={label}
className="max-h-48 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-control bg-surface-muted px-3.5 py-3 font-mono text-12 text-muted outline-none focus-visible:focus-ring"
>
<Payload value={value} />
</pre>
</div>
);
}
export const ToolCallRow = forwardRef<HTMLDivElement, ToolCallRowProps>(function ToolCallRow(
{
name,
summary,
status,
icon,
durationMs,
startedAt,
input,
output,
error,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
onRetry,
labels: labelsProp,
className,
...rest
},
forwardedRef,
) {
const labels = { ...defaultLabels, ...labelsProp };
const reduced = useReducedMotionAfterHydration();
const foldId = useId();
const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
const [retrying, setRetrying] = useState(false);
const expandable = input !== undefined || output !== undefined;
const open = (expandedProp ?? innerExpanded) && expandable;
const live = useElapsed(startedAt, status === "running");
const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${status}`);
const setRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef, containerRef],
);
const toggle = () => {
const next = !open;
if (expandedProp === undefined) setInnerExpanded(next);
onExpandedChange?.(next);
};
const retry = async () => {
if (!onRetry || retrying) return;
setRetrying(true);
try {
await onRetry();
} finally {
setRetrying(false);
}
};
const time =
status === "running" ? (
live !== null ? (
<Ticker value={formatLive(live)} reduced={reduced} />
) : null
) : status === "done" && durationMs !== undefined ? (
<span className="numeric">{formatDuration(durationMs)}</span>
) : null;
const headerContent = (
<>
<span className="grid size-7 shrink-0 place-items-center rounded-control bg-surface-muted text-foreground [&_svg]:size-3.5">{icon}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate text-foreground">{summary ?? name}</span>
{summary ? <span className="min-w-0 shrink-[2] truncate font-mono text-12 text-muted max-sm:hidden">{name}</span> : null}
</span>
{status === "error" && error ? <span className="min-w-0 truncate text-12 text-danger">{error}</span> : null}
</span>
<span className="flex shrink-0 items-center gap-2 text-12 text-muted">
{status === "error" ? <span className="text-danger">{labels.failed}</span> : null}
{status === "running" ? <span className="sr-only">{labels.running}</span> : null}
{time}
</span>
{expandable ? <Chevron open={open} /> : null}
</>
);
const headerClass = "relative flex min-w-0 flex-1 items-center gap-3 rounded-control px-2 py-1.5 text-left";
return (
<div
ref={setRef}
data-status={status}
data-expanded={open}
aria-busy={status === "running"}
className={cn("relative w-full min-w-0 overflow-hidden font-body text-14 text-foreground", className)}
{...rest}
>
<div ref={measureRef}>
<div className="relative flex min-w-0 items-center gap-1">
{expandable ? (
<button
type="button"
data-slot="tool-call-row-trigger"
aria-expanded={open}
aria-controls={foldId}
onClick={toggle}
className={cn(
headerClass,
"outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
)}
>
{headerContent}
</button>
) : (
<div className={headerClass}>{headerContent}</div>
)}
{status === "error" && onRetry ? (
<Button variant="secondary" size="sm" loading={retrying} onClick={() => void retry()} className="shrink-0">
{retrying ? labels.retrying : labels.retry}
</Button>
) : null}
{status === "running" ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null}
</div>
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="fold"
id={foldId}
data-slot="tool-call-row-fold"
className="flex flex-col gap-3 pt-1.5 pe-2 pb-3 ps-12"
initial={reduced ? { opacity: 0 } : { opacity: 0, y: motionTokens.distance.micro, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }}
transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item }}
>
{input !== undefined ? <PayloadBlock label={labels.input} value={input} labels={labels} reduced={reduced} /> : null}
{output !== undefined ? <PayloadBlock label={labels.output} value={output} labels={labels} reduced={reduced} /> : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
});
export type ToolCallRowGroupCounts = {
total: number;
running: number;
done: number;
error: number;
/** Sum of the finished calls' durations (ms). */
durationMs: number;
};
export type ToolCallRowGroupProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
/** ToolCallRow elements. The group reads their status, duration and icon to write its summary. */
children: ReactNode;
/** The summary line. A function receives the counts; the default reads "Used 4 tools" or "2 of 4 tools done". */
summary?: ReactNode | ((counts: ToolCallRowGroupCounts) => ReactNode);
/** Controlled: whether the rows are unfolded. */
expanded?: boolean;
/** Starting fold when uncontrolled. */
defaultExpanded?: boolean;
/** Called when the person folds or unfolds the group. */
onExpandedChange?: (expanded: boolean) => void;
};
const defaultSummary = (c: ToolCallRowGroupCounts) => {
const noun = c.total === 1 ? "tool" : "tools";
if (c.running > 0) return `${c.done + c.error} of ${c.total} ${noun} done`;
if (c.error > 0) return `Used ${c.total} ${noun}, ${c.error} failed`;
return `Used ${c.total} ${noun}`;
};
export const ToolCallRowGroup = forwardRef<HTMLDivElement, ToolCallRowGroupProps>(function ToolCallRowGroup(
{ children, summary = defaultSummary, expanded: expandedProp, defaultExpanded = false, onExpandedChange, className, ...rest },
forwardedRef,
) {
const reduced = useReducedMotionAfterHydration();
const listId = useId();
const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
const open = expandedProp ?? innerExpanded;
const rows = Children.toArray(children).filter(isValidElement) as ReactElement<Partial<ToolCallRowProps>>[];
const counts: ToolCallRowGroupCounts = { total: rows.length, running: 0, done: 0, error: 0, durationMs: 0 };
for (const row of rows) {
const s = row.props.status;
if (s === "running") counts.running++;
else if (s === "error") counts.error++;
else if (s === "done") counts.done++;
if (s !== "running" && typeof row.props.durationMs === "number") counts.durationMs += row.props.durationMs;
}
const icons = rows.map((row) => row.props.icon).filter(Boolean).slice(0, 3);
const status: ToolCallRowStatus = counts.running > 0 ? "running" : counts.error > 0 ? "error" : "done";
const line = typeof summary === "function" ? summary(counts) : summary;
const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${rows.length}`);
const setRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef, containerRef],
);
const toggle = () => {
if (expandedProp === undefined) setInnerExpanded(!open);
onExpandedChange?.(!open);
};
// Up and Down walk the row triggers, Home and End jump to the ends.
const handleListKeys = (event: KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="tool-call-row-trigger"]'));
const current = triggers.indexOf(event.target as HTMLElement);
if (current === -1) return;
event.preventDefault();
const next =
event.key === "Home" ? 0 : event.key === "End" ? triggers.length - 1 : Math.min(triggers.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1)));
triggers[next]?.focus();
};
const enter = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: reducedTransition }
: {
initial: { opacity: 0, y: motionTokens.distance.small, filter: `blur(${motionTokens.blur.subtle}px)` },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter },
};
return (
<div
ref={setRef}
data-status={status}
data-expanded={open}
aria-busy={counts.running > 0}
className={cn("relative w-full min-w-0 overflow-hidden rounded-panel bg-surface font-body text-14 text-foreground shadow-resting hairline", className)}
{...rest}
>
<div ref={measureRef} className="p-1.5">
<button
type="button"
aria-expanded={open}
aria-controls={listId}
onClick={toggle}
className="relative flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-1.5 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus"
>
<span className="flex shrink-0 items-center">
{icons.length > 0 ? (
icons.map((icon, index) => (
<span
key={index}
className={cn(
"grid size-7 place-items-center rounded-control bg-surface-muted text-foreground ring-2 ring-surface [&_svg]:size-3.5",
index > 0 && "-ms-2",
)}
>
{icon}
</span>
))
) : (
<span className="size-7" />
)}
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{line}</span>
{counts.running === 0 && counts.durationMs > 0 ? <span className="numeric shrink-0 text-12 text-muted">{formatDuration(counts.durationMs)}</span> : null}
<Chevron open={open} />
{counts.running > 0 && !open ? <IndeterminateBar reduced={reduced} className="right-2 bottom-0 left-12" /> : null}
</button>
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="rows"
id={listId}
role="group"
aria-label={typeof line === "string" ? line : undefined}
className="mt-1 flex flex-col border-t border-border-subtle pt-1"
onKeyDown={handleListKeys}
initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={reduced ? { opacity: 1 } : { opacity: 1, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }}
transition={reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }}
>
<AnimatePresence initial={false}>
{rows.map((row, index) => (
<motion.div key={row.key ?? index} {...enter}>
{row}
</motion.div>
))}
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
});Usage#
Use it when
- Showing the tools an agent called inside a chat message or a run, with evidence a person can open.
- Debug and audit views where people copy a call's arguments or result.
Use something else when
- The steps of a whole run with approvals: use run-timeline (it can hold tool rows as children).
- A long log of hundreds of calls: use a virtualised table.
- Status while a model is thinking with no tools involved: use thinking-state.
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { CreditCard, Database, RotateCcw, SquareKanban, Ticket } from "lucide-react";
import { motionTokens } from "@/components/pixilui/motion-tokens";
import { Button } from "@/components/pixilui/button";
import { ToolCallRow, ToolCallRowGroup, type ToolCallRowStatus, type ToolCallRowValue } from "@/components/pixilui/tool-call-row";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
state: ["play", "running", "error", "done"],
layout: ["group", "rows"],
} as const;
type Call = {
id: string;
name: string;
icon: ReactNode;
running: string;
done: string;
durationMs: number;
input: ToolCallRowValue;
output: ToolCallRowValue;
};
const CALLS: Call[] = [
{
id: "zendesk",
name: "zendesk.search_tickets",
icon: <Ticket strokeWidth={1.5} />,
running: "Searching tickets about refund delays",
done: "Searched 14 tickets about refund delays",
durationMs: 1240,
input: { query: "refund status:open created>72hours", limit: 25 },
output: {
count: 14,
tickets: [
{ id: "ZD-88412", subject: "Refund still pending after 5 days", requester: "maya.okafor@lumenhaus.com" },
{ id: "ZD-88397", subject: "Where is my money back?", requester: "j.lindqvist@fjordlight.se" },
{ id: "ZD-88351", subject: "Charged twice, refund promised", requester: "dev@parcelpine.io" },
],
},
},
{
id: "stripe",
name: "stripe.refunds.list",
icon: <CreditCard strokeWidth={1.5} />,
running: "Checking refunds in Stripe",
done: "Found 9 refunds stuck in pending",
durationMs: 830,
input: { status: "pending", created: { lt: "2026-10-22T00:00:00Z" }, limit: 100 },
output: {
data: [
{ id: "re_3QxT2bL8kZ", amount: 4900, currency: "usd", status: "pending" },
{ id: "re_3QxR9mC1aP", amount: 12900, currency: "usd", status: "pending" },
],
has_more: false,
total_count: 9,
},
},
{
id: "postgres",
name: "postgres.query",
icon: <Database strokeWidth={1.5} />,
running: "Matching refunds to accounts",
done: "Matched 9 refunds to 7 accounts",
durationMs: 2310,
input: "select a.id, a.name, r.refund_id\nfrom refunds r\njoin accounts a on a.id = r.account_id\nwhere r.refund_id = any($1);",
output: { rows: 9, accounts: 7, duplicates_merged: 2 },
},
{
id: "linear",
name: "linear.create_issue",
icon: <SquareKanban strokeWidth={1.5} />,
running: "Opening an issue for the payout webhook",
done: "Opened ENG-4127 for the payout webhook",
durationMs: 960,
input: { team: "ENG", title: "Payout webhook drops refund.updated events", priority: 2 },
output: { id: "ENG-4127", url: "https://linear.app/northwind/issue/ENG-4127" },
},
];
type CallState = { status: ToolCallRowStatus; startedAt?: number };
type Frame = { calls: Record<string, CallState>; open: string[]; groupOpen: boolean };
const ERROR_TEXT = "Query timed out after 30s on replica db-read-2";
const done = (ids: string[]): Record<string, CallState> => Object.fromEntries(ids.map((id) => [id, { status: "done" as const }]));
const FINAL: Frame = { calls: done(CALLS.map((c) => c.id)), open: ["postgres"], groupOpen: true };
const EMPTY: Frame = { calls: {}, open: [], groupOpen: true };
export default function ToolCallRowDemo({ state = "play", layout = "group" }: KnobValues<typeof knobs>) {
const reduced = useReducedMotionAfterHydration();
const [run, setRun] = useState(0);
const [frame, setFrame] = useState<Frame>(EMPTY);
const [finished, setFinished] = useState(false);
const playing = state === "play" && !reduced;
// A knob change resets the frame to that state.
const combo = `${state}-${layout}-${reduced}`;
const [seen, setSeen] = useState<string | null>(null);
if (seen !== combo) {
setSeen(combo);
setFinished(false);
setFrame(
playing
? EMPTY
: state === "play" || state === "done"
? { ...FINAL, groupOpen: state === "play" }
: state === "running"
? { calls: { ...done(["zendesk", "stripe"]), postgres: { status: "running" } }, open: [], groupOpen: true }
: { calls: { ...done(["zendesk", "stripe"]), postgres: { status: "error" } }, open: [], groupOpen: true },
);
}
useEffect(() => {
if (!playing) return;
const set = (id: string, call: CallState) => setFrame((f) => ({ ...f, calls: { ...f.calls, [id]: call } }));
const start = (id: string) => () => set(id, { status: "running", startedAt: Date.now() });
const finish = (id: string) => () => set(id, { status: "done" });
const steps: [number, () => void][] = [
[300, start("zendesk")],
[1300, finish("zendesk")],
[1480, start("stripe")],
[2280, finish("stripe")],
[2460, start("postgres")],
[3300, () => set("postgres", { status: "error" })],
[4300, start("postgres")],
[5100, finish("postgres")],
[5280, start("linear")],
[6100, finish("linear")],
[6800, () => setFrame((f) => ({ ...f, open: ["postgres"] }))],
[7400, () => setFinished(true)],
];
const timers = steps.map(([at, fn]) => setTimeout(fn, at));
return () => timers.forEach(clearTimeout);
}, [playing, run]);
// The running state keeps a live timer.
useEffect(() => {
if (state !== "running" || reduced) return;
const id = setTimeout(() => setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "running", startedAt: Date.now() - 1400 } } })), 0);
return () => clearTimeout(id);
}, [state, reduced, run]);
const retry = async () => {
setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "running", startedAt: Date.now() } } }));
await new Promise((resolve) => setTimeout(resolve, 1100));
setFrame((f) => ({ ...f, calls: { ...f.calls, postgres: { status: "done" } } }));
};
const rows = CALLS.filter((call) => frame.calls[call.id]).map((call) => {
const { status, startedAt } = frame.calls[call.id]!;
return (
<ToolCallRow
key={call.id}
name={call.name}
icon={call.icon}
status={status}
summary={status === "done" ? call.done : status === "error" ? "Could not match refunds to accounts" : call.running}
startedAt={startedAt}
durationMs={status === "error" ? 30000 : call.durationMs}
error={status === "error" ? ERROR_TEXT : undefined}
input={call.input}
output={status === "done" ? call.output : undefined}
expanded={frame.open.includes(call.id)}
onExpandedChange={(open) => setFrame((f) => ({ ...f, open: open ? [...f.open, call.id] : f.open.filter((id) => id !== call.id) }))}
onRetry={state === "error" ? retry : undefined}
/>
);
});
return (
<div className="flex w-full min-w-0 max-w-xl flex-col gap-4">
<div className="min-h-96">
{rows.length === 0 ? null : layout === "group" ? (
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.small, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }}
>
<ToolCallRowGroup expanded={frame.groupOpen} onExpandedChange={(open) => setFrame((f) => ({ ...f, groupOpen: open }))}>
{rows}
</ToolCallRowGroup>
</motion.div>
) : (
<div className="flex flex-col gap-1">{rows}</div>
)}
</div>
<div className="flex h-control-sm justify-center">
{playing && finished ? (
<Button
variant="ghost"
size="sm"
icon={<RotateCcw strokeWidth={1.5} />}
onClick={() => {
setFrame(EMPTY);
setFinished(false);
setRun((n) => n + 1);
}}
>
Replay
</Button>
) : null}
</div>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | string | The tool, as its code name: "zendesk.search_tickets". Set in mono. | |
summary | ReactNode | What the call did, in plain words and past tense once done: "Searched 14 tickets". | |
statusrequired | "running" | "done" | "error" | Running shows a thin indeterminate bar; error shows the message and Retry. | |
icon | ReactNode | The tool's mark, shown in a small tile. | |
durationMs | number | How long the call took (ms). Shown once it finishes. | |
startedAt | number | When the call started (epoch ms). Drives a live timer while running. | |
input | ToolCallRowValue | What the agent sent. Objects are formatted as JSON. | |
output | ToolCallRowValue | What the tool returned. Objects are formatted as JSON. | |
error | ReactNode | Why it failed, in a sentence. Shown under the summary on error. | |
expanded | boolean | Controlled: whether the input and output are unfolded. | |
defaultExpanded | boolean | false | Starting fold when uncontrolled. |
onExpandedChange | (expanded: boolean) => void | Called when the person folds or unfolds the row. | |
onRetry | () => void | Promise<void> | Shows Retry on error. Return a promise and the button shows its own pending state. | |
labels | Partial<ToolCallRowLabels> | Copy overrides, for translation. |
Also takes every native <div> attribute, and a ref to it.
Keyboard#
- Tab
- Moves to a row's trigger, then its copy buttons and output blocks when open, then Retry on a failed call.
- Enter
- Folds or unfolds the row, or the group from its summary line.
- Space
- Folds or unfolds the row, or the group from its summary line.
- ArrowDownArrowUp
- Inside a group, moves between row triggers.
- HomeEnd
- Inside a group, jumps to the first or last row.
Accessibility#
- Each row trigger is a button with aria-expanded and aria-controls; rows with nothing to show are plain text.
- Running rows set aria-busy and carry the word Running in text; failed rows show Failed in text, not only in colour.
- The input and output blocks are focusable and labelled, so keyboard users can scroll them; copy buttons say what they copy.
- Retry uses aria-busy while the retry runs, never disabled, so focus stays on it.
- The group's rows are a labelled group named by the summary line.
Motion#
- Running shows a thin bar under the row with a segment sliding across on linear easing, not a spinner. Reduced motion keeps the track still.
- The live timer's digits are keyed by place and roll up on the snappy spring.
- Unfolding grows the row on the morph spring and folding shrinks it on the smooth spring; the payload fades up out of a 2px blur.
- Rows added to an open group rise in from 6px out of a 2px blur while the group grows to fit.
- Copy swaps to a check and Copied with a 150ms blur crossfade.
- Reduced motion: instant folds, short fades, no sliding bar.
Responsive#
- Fills its container. The summary truncates first; the tool name hides under 640px so the summary and timing keep their room.
- Input and output blocks wrap long lines and scroll past 192px tall.
Performance#
- One ResizeObserver per row and per group drives the folds; nothing animates on first render.
- Payloads are formatted only while a row is open.
Notes for AI#
- Write summary in past tense with a number once done ("Searched 14 tickets") and in the present while running ("Searching tickets").
- Pass input and output as plain JSON values or strings (SQL, shell commands); objects are pretty-printed with keys a step quieter.
- Pass startedAt while running for a live timer and durationMs once done.
- Wrap rows in ToolCallRowGroup to sum them; it reads each row's status, duration and icon, so keep the rows as direct children with stable keys.
- Return a promise from onRetry and the button shows its own pending state; update status to running from your store when the retry starts.
- Pass the tool's logo as icon; the component never imports an icon library.