Tool Call Timeline
AIThe collapsible agent trace from AI product UIs — nested tool calls with running / done / failed states, a live duration counter, and per-call results. Recursive tree layout with a connector spine; expand/collapse animates height via the grid-rows technique (no reflow flicker).
Found 3 sources · ranked by relevance
200 OK · 14.2 KB · text/html
components/ui/hero.tsx · +42 −0
components/ui/tool-call-timeline.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ToolCallTimeline — the collapsible agent trace from AI product UIs.
*
* <ToolCallTimeline>
* <ToolCall name="search_web" status="success" duration={0.4}>
* <ToolCallResult>3 results</ToolCallResult>
* <ToolCall name="fetch_page" status="success" duration={0.2} />
* </ToolCall>
* <ToolCall name="write_file" status="running" />
* </ToolCallTimeline>
*
* A `ToolCall` nests other `ToolCall`s to form a tree (recursive layout with a
* connector spine); each is an independent disclosure that expands its result +
* children. Expand/collapse uses the `grid-template-rows: 0fr → 1fr` technique
* so height animates from the real content size with no reflow flicker. While a
* call is `running` it auto-expands, shows a live duration counter, and a
* spinner; it auto-collapses to a one-line summary when it settles.
*/
export type ToolCallStatus = "pending" | "running" | "success" | "error";
const STATUS_TEXT: Record<ToolCallStatus, string> = {
pending: "queued",
running: "running",
success: "done",
error: "failed",
};
/** Live seconds counter while `active`, throttled to ~10fps. */
function useElapsed(active: boolean): number {
const [elapsed, setElapsed] = React.useState(0);
const [prevActive, setPrevActive] = React.useState(active);
// Reset the counter the moment it (re)activates — during render, not in an
// effect, so there's no cascading-render lint and no stale flash.
if (active !== prevActive) {
setPrevActive(active);
if (active) setElapsed(0);
}
React.useEffect(() => {
if (!active) return;
const start = performance.now();
const id = window.setInterval(
() => setElapsed((performance.now() - start) / 1000),
100,
);
return () => window.clearInterval(id);
}, [active]);
return elapsed;
}
function StatusIcon({ status }: { status: ToolCallStatus }) {
if (status === "running") {
return (
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 dark:border-zinc-800 dark:border-t-zinc-50"
/>
);
}
if (status === "success") {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
if (status === "error") {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
return (
<span
aria-hidden="true"
className="size-3.5 shrink-0 rounded-full border-2 border-zinc-200 dark:border-zinc-800"
/>
);
}
export function ToolCallTimeline({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="tool-call-timeline"
className={cn(
"rounded-xl border border-zinc-200 bg-white/50 p-1.5 text-sm dark:border-zinc-800 dark:bg-zinc-900/50",
className,
)}
{...props}
/>
);
}
export interface ToolCallProps extends Omit<
React.ComponentProps<"div">,
"onChange"
> {
name: string;
status?: ToolCallStatus;
/** Seconds the call took, shown once it has settled. */
duration?: number;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function ToolCall({
name,
status = "pending",
duration,
defaultOpen,
open: openProp,
onOpenChange,
className,
children,
...props
}: ToolCallProps) {
const isControlled = openProp !== undefined;
const [openState, setOpenState] = React.useState(
defaultOpen ?? status === "running",
);
const [prevStatus, setPrevStatus] = React.useState(status);
const contentId = React.useId();
const hasContent = React.Children.count(children) > 0;
const elapsed = useElapsed(status === "running");
// Auto-expand while running, auto-collapse once it settles (uncontrolled).
// Adjusting state during render is the recommended way to react to a prop
// change — no effect, no flash.
if (status !== prevStatus) {
setPrevStatus(status);
if (!isControlled) {
if (status === "running") setOpenState(true);
else if (status === "success" || status === "error") setOpenState(false);
}
}
const open = isControlled ? openProp : openState;
const toggle = () => {
const next = !(isControlled ? openProp : openState);
if (!isControlled) setOpenState(next);
onOpenChange?.(next);
};
const timeLabel =
status === "running"
? `${elapsed.toFixed(1)}s`
: duration != null
? `${duration.toFixed(1)}s`
: null;
const header = (
<>
<span className="flex min-w-0 items-center gap-2">
<StatusIcon status={status} />
<span className="truncate font-mono text-[13px] text-zinc-950 dark:text-zinc-50">
{name}
</span>
<span aria-hidden="true" className="text-zinc-200 dark:text-zinc-800">
→
</span>
<span
className={cn(
"font-mono text-xs",
status === "error"
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 dark:text-zinc-400",
)}
>
{STATUS_TEXT[status]}
</span>
</span>
<span className="flex shrink-0 items-center gap-2 pl-2">
{timeLabel && (
<span className="font-mono text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
{timeLabel}
</span>
)}
{hasContent && (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn(
"size-3 text-zinc-500 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] dark:text-zinc-400",
open && "rotate-90",
)}
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</span>
</>
);
return (
<div
data-slot="tool-call"
data-status={status}
className={className}
{...props}
>
{hasContent ? (
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
{header}
</button>
) : (
<div className="flex w-full items-center justify-between gap-2 px-2 py-1.5">
{header}
</div>
)}
{hasContent && (
<div
id={contentId}
role="region"
data-state={open ? "open" : "closed"}
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
{/* the tree spine: nested calls + result indent under this row */}
<div className="mt-0.5 ml-[15px] space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-800">
{children}
</div>
</div>
</div>
)}
</div>
);
}
export function ToolCallResult({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="tool-call-result"
className={cn(
"my-1 rounded-xl border border-zinc-200 bg-white px-3 py-2 font-mono text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
className,
)}
{...props}
/>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/tool-call-timeline.json1. Install dependencies
Terminal
npm install clsx tailwind-merge2. Copy the source into your project
components/ui/tool-call-timeline.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ToolCallTimeline — the collapsible agent trace from AI product UIs.
*
* <ToolCallTimeline>
* <ToolCall name="search_web" status="success" duration={0.4}>
* <ToolCallResult>3 results</ToolCallResult>
* <ToolCall name="fetch_page" status="success" duration={0.2} />
* </ToolCall>
* <ToolCall name="write_file" status="running" />
* </ToolCallTimeline>
*
* A `ToolCall` nests other `ToolCall`s to form a tree (recursive layout with a
* connector spine); each is an independent disclosure that expands its result +
* children. Expand/collapse uses the `grid-template-rows: 0fr → 1fr` technique
* so height animates from the real content size with no reflow flicker. While a
* call is `running` it auto-expands, shows a live duration counter, and a
* spinner; it auto-collapses to a one-line summary when it settles.
*/
export type ToolCallStatus = "pending" | "running" | "success" | "error";
const STATUS_TEXT: Record<ToolCallStatus, string> = {
pending: "queued",
running: "running",
success: "done",
error: "failed",
};
/** Live seconds counter while `active`, throttled to ~10fps. */
function useElapsed(active: boolean): number {
const [elapsed, setElapsed] = React.useState(0);
const [prevActive, setPrevActive] = React.useState(active);
// Reset the counter the moment it (re)activates — during render, not in an
// effect, so there's no cascading-render lint and no stale flash.
if (active !== prevActive) {
setPrevActive(active);
if (active) setElapsed(0);
}
React.useEffect(() => {
if (!active) return;
const start = performance.now();
const id = window.setInterval(
() => setElapsed((performance.now() - start) / 1000),
100,
);
return () => window.clearInterval(id);
}, [active]);
return elapsed;
}
function StatusIcon({ status }: { status: ToolCallStatus }) {
if (status === "running") {
return (
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 dark:border-zinc-800 dark:border-t-zinc-50"
/>
);
}
if (status === "success") {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
if (status === "error") {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
return (
<span
aria-hidden="true"
className="size-3.5 shrink-0 rounded-full border-2 border-zinc-200 dark:border-zinc-800"
/>
);
}
export function ToolCallTimeline({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="tool-call-timeline"
className={cn(
"rounded-xl border border-zinc-200 bg-white/50 p-1.5 text-sm dark:border-zinc-800 dark:bg-zinc-900/50",
className,
)}
{...props}
/>
);
}
export interface ToolCallProps extends Omit<
React.ComponentProps<"div">,
"onChange"
> {
name: string;
status?: ToolCallStatus;
/** Seconds the call took, shown once it has settled. */
duration?: number;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function ToolCall({
name,
status = "pending",
duration,
defaultOpen,
open: openProp,
onOpenChange,
className,
children,
...props
}: ToolCallProps) {
const isControlled = openProp !== undefined;
const [openState, setOpenState] = React.useState(
defaultOpen ?? status === "running",
);
const [prevStatus, setPrevStatus] = React.useState(status);
const contentId = React.useId();
const hasContent = React.Children.count(children) > 0;
const elapsed = useElapsed(status === "running");
// Auto-expand while running, auto-collapse once it settles (uncontrolled).
// Adjusting state during render is the recommended way to react to a prop
// change — no effect, no flash.
if (status !== prevStatus) {
setPrevStatus(status);
if (!isControlled) {
if (status === "running") setOpenState(true);
else if (status === "success" || status === "error") setOpenState(false);
}
}
const open = isControlled ? openProp : openState;
const toggle = () => {
const next = !(isControlled ? openProp : openState);
if (!isControlled) setOpenState(next);
onOpenChange?.(next);
};
const timeLabel =
status === "running"
? `${elapsed.toFixed(1)}s`
: duration != null
? `${duration.toFixed(1)}s`
: null;
const header = (
<>
<span className="flex min-w-0 items-center gap-2">
<StatusIcon status={status} />
<span className="truncate font-mono text-[13px] text-zinc-950 dark:text-zinc-50">
{name}
</span>
<span aria-hidden="true" className="text-zinc-200 dark:text-zinc-800">
→
</span>
<span
className={cn(
"font-mono text-xs",
status === "error"
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 dark:text-zinc-400",
)}
>
{STATUS_TEXT[status]}
</span>
</span>
<span className="flex shrink-0 items-center gap-2 pl-2">
{timeLabel && (
<span className="font-mono text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
{timeLabel}
</span>
)}
{hasContent && (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn(
"size-3 text-zinc-500 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] dark:text-zinc-400",
open && "rotate-90",
)}
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</span>
</>
);
return (
<div
data-slot="tool-call"
data-status={status}
className={className}
{...props}
>
{hasContent ? (
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
{header}
</button>
) : (
<div className="flex w-full items-center justify-between gap-2 px-2 py-1.5">
{header}
</div>
)}
{hasContent && (
<div
id={contentId}
role="region"
data-state={open ? "open" : "closed"}
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
{/* the tree spine: nested calls + result indent under this row */}
<div className="mt-0.5 ml-[15px] space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-800">
{children}
</div>
</div>
</div>
)}
</div>
);
}
export function ToolCallResult({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="tool-call-result"
className={cn(
"my-1 rounded-xl border border-zinc-200 bg-white px-3 py-2 font-mono text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
className,
)}
{...props}
/>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
ToolCall.name | string | — | The tool's name, shown in mono (e.g. search_web). |
ToolCall.status | "pending" | "running" | "success" | "error" | "pending" | Drives the icon, status text, and auto expand/collapse. Running shows a spinner + live counter. |
ToolCall.duration | number | — | Seconds the call took, shown once it settles. |
ToolCall (children) | ReactNode | — | Nest <ToolCallResult> and further <ToolCall>s to build the tree; a call with children becomes an expandable disclosure. |
open / defaultOpen / onOpenChange | boolean / (open: boolean) => void | — | Control the disclosure. Defaults to open while running. |
Dependencies
clsxtailwind-merge