AI & Chat

Agent Trace Tree

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.

Install

npx shadcn@latest add @paragon/agent-trace-tree

agent-trace-tree.tsx

"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>
  );
}