A nested agent tool-call waterfall with kind icons, status dots, and tabular durations; running nodes shimmer and rows with children expand through a grid-rows transition.
npx shadcn@latest add @paragon/agent-trace-tree"use client";
import * as React from "react";
import { ChevronRight, Wrench, Brain, Search, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
export type TraceStatus = "running" | "done" | "error";
export type TraceKind = "think" | "tool" | "search" | "shell";
export interface TraceNode {
id: string;
label: string;
kind?: TraceKind;
status?: TraceStatus;
/** Duration in ms. Omit while running. */
durationMs?: number;
children?: TraceNode[];
}
export interface AgentTraceTreeProps extends React.ComponentProps<"div"> {
nodes: TraceNode[];
/** Node ids expanded by default. Defaults to all with children. */
defaultExpanded?: string[];
static?: boolean;
}
const kindIcon: Record<TraceKind, React.ComponentType<{ className?: string }>> =
{
think: Brain,
tool: Wrench,
search: Search,
shell: Terminal,
};
function formatDuration(ms: number): string {
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${ms}ms`;
}
function collectExpandable(nodes: TraceNode[], acc: string[] = []): string[] {
for (const n of nodes) {
if (n.children?.length) {
acc.push(n.id);
collectExpandable(n.children, acc);
}
}
return acc;
}
/**
* A nested agent tool-call / step waterfall. Each row shows a kind icon, a
* status dot, and a tabular duration; rows with children expand and collapse
* through a grid-rows transition (0fr↔1fr). Running nodes shimmer via a
* masked gradient sweep that pauses under reduced motion. Fully keyboard
* operable — rows are buttons, arrows are decorative.
*/
export function AgentTraceTree({
nodes,
defaultExpanded,
static: isStatic = false,
className,
...props
}: AgentTraceTreeProps) {
const [expanded, setExpanded] = React.useState<Set<string>>(
() => new Set(defaultExpanded ?? collectExpandable(nodes)),
);
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div
data-slot="agent-trace-tree"
className={cn(
"w-full max-w-lg rounded-xl bg-card p-2 font-mono text-xs shadow-border",
className,
)}
{...props}
>
<style href="paragon-agent-trace-tree" precedence="paragon">{`
@keyframes paragon-trace-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-paragon-trace-shimmer] { animation: none !important; }
}
`}</style>
{nodes.map((node) => (
<TraceRow
key={node.id}
node={node}
depth={0}
expanded={expanded}
toggle={toggle}
isStatic={isStatic}
/>
))}
</div>
);
}
function TraceRow({
node,
depth,
expanded,
toggle,
isStatic,
}: {
node: TraceNode;
depth: number;
expanded: Set<string>;
toggle: (id: string) => void;
isStatic: boolean;
}) {
const status = node.status ?? "done";
const hasChildren = !!node.children?.length;
const isOpen = expanded.has(node.id);
const Icon = kindIcon[node.kind ?? "tool"];
const running = status === "running";
const statusDot =
status === "running"
? "bg-primary"
: status === "error"
? "bg-destructive"
: "bg-success";
const rowInner = (
<span className="flex min-w-0 flex-1 items-center gap-2">
{hasChildren ? (
<ChevronRight
aria-hidden
className={cn(
"size-3.5 shrink-0 text-muted-foreground",
!isStatic &&
"transition-transform duration-(--duration-fast) ease-out",
isOpen && "rotate-90",
)}
/>
) : (
<span aria-hidden className="w-3.5 shrink-0" />
)}
<Icon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
<span
data-paragon-trace-shimmer={running || undefined}
className={cn(
"min-w-0 flex-1 truncate text-left",
running ? "text-foreground" : "text-foreground",
)}
style={
running && !isStatic
? {
background:
"linear-gradient(90deg, var(--color-muted-foreground) 20%, var(--color-foreground) 50%, var(--color-muted-foreground) 80%)",
backgroundSize: "200% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
animation: "paragon-trace-shimmer 1.6s linear infinite",
}
: undefined
}
>
{node.label}
</span>
<span
aria-hidden
className={cn("size-1.5 shrink-0 rounded-full", statusDot)}
/>
<span className="w-12 shrink-0 text-right text-[11px] text-muted-foreground tabular-nums">
{running ? "···" : node.durationMs != null ? formatDuration(node.durationMs) : ""}
</span>
</span>
);
return (
<div>
{hasChildren ? (
<button
type="button"
aria-expanded={isOpen}
onClick={() => toggle(node.id)}
className="flex w-full items-center rounded-md px-1.5 py-1.5 transition-colors duration-(--duration-fast) hover:bg-accent/60"
style={{ paddingLeft: 6 + depth * 16 }}
>
{rowInner}
</button>
) : (
<div
className="flex items-center rounded-md px-1.5 py-1.5"
style={{ paddingLeft: 6 + depth * 16 }}
>
{rowInner}
</div>
)}
{hasChildren && (
<div
className={cn(
"grid",
!isStatic &&
"transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none",
)}
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
>
<div inert={!isOpen} className="min-h-0 overflow-hidden">
{node.children!.map((child) => (
<TraceRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
toggle={toggle}
isStatic={isStatic}
/>
))}
</div>
</div>
)}
</div>
);
}