{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "trace-waterfall",
  "title": "Trace Waterfall",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A trace as a waterfall: spans nested under the span that ran them, each a bar on a shared time axis with its duration, failed spans written out, collapsible branches and the full tree keyboard model.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/trace-waterfall.tsx",
      "content": "// Ballmac UI: Trace Waterfall. https://ui.ballmac.com/components/trace-waterfall\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight, CircleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype SpanKind = \"model\" | \"tool\" | \"retrieval\" | \"agent\" | \"other\"\n\ntype TraceSpan = {\n  id: string\n  /** The span this one runs inside. Leave it out for a top-level span. */\n  parentId?: string\n  name: string\n  kind?: SpanKind\n  /** Start, in milliseconds from the beginning of the run. */\n  start: number\n  /** Length, in milliseconds. */\n  duration: number\n  status?: \"ok\" | \"error\"\n  /** Tokens used, shown in the detail. */\n  tokens?: number\n  /** More detail, shown when the span is selected: an input, an output, an error message. */\n  detail?: string\n}\n\nconst KINDS: Record<SpanKind, { label: string; bar: string }> = {\n  model: { label: \"Model call\", bar: \"bg-chart-1\" },\n  tool: { label: \"Tool call\", bar: \"bg-chart-2\" },\n  retrieval: { label: \"Retrieval\", bar: \"bg-chart-4\" },\n  agent: { label: \"Agent step\", bar: \"bg-chart-5\" },\n  other: { label: \"Other\", bar: \"bg-muted-foreground/60\" },\n}\n\n/** 420 → \"420 ms\", 1240 → \"1.24 s\", 75000 → \"1 min 15 s\". */\nfunction formatMs(ms: number) {\n  if (!Number.isFinite(ms) || ms < 0) return \"0 ms\"\n  if (ms < 1000) return `${Math.round(ms)} ms`\n  const s = ms / 1000\n  if (s < 60) return `${+s.toFixed(2)} s`\n  const m = Math.floor(s / 60)\n  const r = Math.round(s - m * 60)\n  return r === 0 ? `${m} min` : `${m} min ${r} s`\n}\n\ntype Row = { span: TraceSpan; level: number; setsize: number; pos: number; hasChildren: boolean }\n\n/** Spans as a depth-first list: each span followed by its children in start order. A span whose parent is missing counts as top level. */\nfunction flatten(spans: TraceSpan[], collapsed: Set<string>): Row[] {\n  const ids = new Set(spans.map((s) => s.id))\n  const kids = new Map<string | undefined, TraceSpan[]>()\n  for (const s of spans) {\n    const p = s.parentId && ids.has(s.parentId) && s.parentId !== s.id ? s.parentId : undefined\n    kids.set(p, [...(kids.get(p) ?? []), s])\n  }\n  for (const [, list] of kids) list.sort((a, b) => a.start - b.start)\n  const rows: Row[] = []\n  const seen = new Set<string>()\n  const walk = (parent: string | undefined, level: number) => {\n    const list = kids.get(parent) ?? []\n    list.forEach((span, i) => {\n      if (seen.has(span.id)) return\n      seen.add(span.id)\n      const has = (kids.get(span.id) ?? []).length > 0\n      rows.push({ span, level, setsize: list.length, pos: i + 1, hasChildren: has })\n      if (has && !collapsed.has(span.id)) walk(span.id, level + 1)\n    })\n  }\n  walk(undefined, 1)\n  return rows\n}\n\ntype TraceWaterfallProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  spans: TraceSpan[]\n  /** Length of the whole run in milliseconds. Defaults to the end of the last span. */\n  total?: number\n  /** The selected span id. Controlled. */\n  selected?: string | null\n  /** The selected span id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when a span is selected. */\n  onSelectedChange?: (id: string) => void\n  /** Spans that start collapsed. */\n  defaultCollapsed?: string[]\n}\n\n/**\n * A trace as a waterfall: spans nested under the span that ran them, each a bar on a shared time axis, with its duration beside it. It is a real tree: Up and Down\n * move, Right opens or goes in, Left closes or goes out, Enter selects. Failed spans say \"Failed\" and show an icon. The bars are drawn with the theme's chart colours.\n */\nfunction TraceWaterfall({ spans, total: totalProp, selected: selectedProp, defaultSelected = null, onSelectedChange, defaultCollapsed = [], className, ...props }: TraceWaterfallProps) {\n  const msg = useMessages()\n  const [collapsed, setCollapsed] = React.useState(() => new Set(defaultCollapsed))\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const total = Math.max(totalProp ?? 0, ...spans.map((s) => s.start + s.duration), 1)\n  const rows = React.useMemo(() => flatten(spans, collapsed), [spans, collapsed])\n  const [focus, setFocus] = React.useState<string | null>(null)\n  const tabbable = (focus && rows.some((r) => r.span.id === focus) ? focus : selected && rows.some((r) => r.span.id === selected) ? selected : rows[0]?.span.id) ?? null\n  const refs = React.useRef(new Map<string, HTMLDivElement>())\n  const chosen = spans.find((s) => s.id === selected)\n  const kindText = (k: SpanKind | undefined) => msg(`trace-waterfall.kind.${k ?? \"other\"}`, KINDS[k ?? \"other\"].label)\n  const parentOf = (s: TraceSpan) => rows.find((r) => r.span.id === s.parentId)\n\n  function select(id: string) {\n    if (selectedProp === undefined) setInner(id)\n    onSelectedChange?.(id)\n  }\n  function go(id: string | undefined) {\n    if (!id) return\n    setFocus(id)\n    refs.current.get(id)?.focus()\n  }\n  function toggle(id: string, open: boolean) {\n    setCollapsed((c) => {\n      const n = new Set(c)\n      if (open) n.delete(id)\n      else n.add(id)\n      return n\n    })\n  }\n  function onKeyDown(e: React.KeyboardEvent, i: number) {\n    const row = rows[i]\n    if (e.key === \"ArrowDown\") go(rows[i + 1]?.span.id)\n    else if (e.key === \"ArrowUp\") go(rows[i - 1]?.span.id)\n    else if (e.key === \"Home\") go(rows[0]?.span.id)\n    else if (e.key === \"End\") go(rows[rows.length - 1]?.span.id)\n    else if (e.key === \"ArrowRight\") {\n      if (row.hasChildren && collapsed.has(row.span.id)) toggle(row.span.id, true)\n      else if (row.hasChildren) go(rows[i + 1]?.span.id)\n    } else if (e.key === \"ArrowLeft\") {\n      if (row.hasChildren && !collapsed.has(row.span.id)) toggle(row.span.id, false)\n      else go(parentOf(row.span)?.span.id)\n    } else if (e.key === \"Enter\" || e.key === \" \") select(row.span.id)\n    else return\n    e.preventDefault()\n  }\n\n  const ticks = [0, 0.25, 0.5, 0.75, 1]\n  return (\n    <div data-slot=\"trace-waterfall\" className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div className=\"overflow-x-auto rounded-xl border bg-card text-card-foreground\" dir=\"ltr\">\n        <div className=\"min-w-[36rem]\">\n          <div className=\"grid grid-cols-[minmax(12rem,2fr)_3fr] border-b bg-muted/30 text-[11px] text-muted-foreground\">\n            <div className=\"px-3 py-2 font-medium\">{msg(\"trace-waterfall.span\", \"Step\")}</div>\n            <div className=\"relative h-8\" aria-hidden=\"true\">\n              {ticks.map((t) => (\n                <span key={t} className=\"absolute top-0 flex h-full items-center font-mono tabular-nums\" style={{ left: `${t * 100}%`, transform: t === 0 ? undefined : t === 1 ? \"translateX(-100%)\" : \"translateX(-50%)\", paddingInline: t === 0 || t === 1 ? 8 : 0 }}>\n                  {formatMs(total * t)}\n                </span>\n              ))}\n            </div>\n          </div>\n          <div role=\"tree\" aria-label={msg(\"trace-waterfall.label\", \"Spans of the run\")} className=\"divide-y\">\n            {rows.map((r, i) => {\n              const s = r.span\n              const k = KINDS[s.kind ?? \"other\"]\n              const on = selected === s.id\n              const failed = s.status === \"error\"\n              const open = r.hasChildren && !collapsed.has(s.id)\n              return (\n                <div\n                  key={s.id}\n                  ref={(el) => {\n                    if (el) refs.current.set(s.id, el)\n                    else refs.current.delete(s.id)\n                  }}\n                  role=\"treeitem\"\n                  aria-level={r.level}\n                  aria-setsize={r.setsize}\n                  aria-posinset={r.pos}\n                  aria-expanded={r.hasChildren ? open : undefined}\n                  aria-selected={on}\n                  aria-label={msg(\"trace-waterfall.item\", \"{name}, {kind}, {duration}, starts at {start}{failed}\", { name: s.name, kind: kindText(s.kind).toLowerCase(), duration: formatMs(s.duration), start: formatMs(s.start), failed: failed ? `, ${msg(\"trace-waterfall.failed\", \"failed\").toLowerCase()}` : \"\" })}\n                  tabIndex={tabbable === s.id ? 0 : -1}\n                  onClick={() => select(s.id)}\n                  onFocus={() => setFocus(s.id)}\n                  onKeyDown={(e) => onKeyDown(e, i)}\n                  className={cn(\"grid cursor-default grid-cols-[minmax(12rem,2fr)_3fr] items-center outline-none transition-colors hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset motion-reduce:transition-none\", on && \"bg-accent/60\")}\n                >\n                  <div className=\"flex min-w-0 items-center gap-1 py-2 pe-2 text-[13px]\" style={{ paddingInlineStart: 8 + (r.level - 1) * 16 }}>\n                    {r.hasChildren ? (\n                      <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" onClick={(e) => { e.stopPropagation(); toggle(s.id, !open) }} className=\"flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent\">\n                        <ChevronRight className={cn(\"size-3.5 transition-transform motion-reduce:transition-none rtl:rotate-180\", open && \"rotate-90 rtl:rotate-90\")} />\n                      </button>\n                    ) : (\n                      <span aria-hidden=\"true\" className=\"size-5 shrink-0\" />\n                    )}\n                    <span className=\"truncate font-medium text-foreground\">{s.name}</span>\n                    {failed && <CircleAlert aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-destructive\" />}\n                  </div>\n                  <div className=\"relative h-9\" aria-hidden=\"true\">\n                    {ticks.slice(1, -1).map((t) => (\n                      <span key={t} className=\"absolute inset-y-0 w-px bg-border/60\" style={{ left: `${t * 100}%` }} />\n                    ))}\n                    <span className={cn(\"absolute top-2 bottom-2 min-w-[3px] rounded-[3px]\", failed ? \"bg-destructive\" : k.bar)} style={{ left: `${(s.start / total) * 100}%`, width: `${Math.max((s.duration / total) * 100, 0.4)}%` }} />\n                    <span className=\"absolute top-1/2 -translate-y-1/2 ms-1.5 rounded-sm bg-card px-1 font-mono text-[11px] whitespace-nowrap tabular-nums text-muted-foreground\" style={{ left: `${Math.min(((s.start + s.duration) / total) * 100, 86)}%` }}>\n                      {failed ? `${msg(\"trace-waterfall.failedShort\", \"Failed\")} · ` : \"\"}{formatMs(s.duration)}\n                    </span>\n                  </div>\n                </div>\n              )\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div aria-live=\"polite\" className=\"min-h-[3.5rem] rounded-xl border bg-card p-4 text-sm\">\n        {chosen ? (\n          <div className=\"grid gap-1\">\n            <p className=\"flex flex-wrap items-center gap-x-2 font-medium\">\n              {chosen.name}\n              <span className=\"rounded-full border px-2 py-0.5 text-[11px] font-normal text-muted-foreground\">{kindText(chosen.kind)}</span>\n              {chosen.status === \"error\" && <span className=\"text-[11px] font-medium text-destructive\">{msg(\"trace-waterfall.failedShort\", \"Failed\")}</span>}\n            </p>\n            <p className=\"font-mono text-xs tabular-nums text-muted-foreground\">\n              {formatMs(chosen.start)} → {formatMs(chosen.start + chosen.duration)} · {formatMs(chosen.duration)} · {msg(\"trace-waterfall.share\", \"{percent}% of the run\", { percent: Math.round((chosen.duration / total) * 1000) / 10 })}{chosen.tokens !== undefined ? ` · ${chosen.tokens.toLocaleString(\"en-US\")} tokens` : \"\"}\n            </p>\n            {chosen.detail && <p className=\"text-muted-foreground\" dir=\"auto\">{chosen.detail}</p>}\n          </div>\n        ) : (\n          <p className=\"text-muted-foreground\">{msg(\"trace-waterfall.hint\", \"Select a span to see its timing. Right opens a span, Left closes it.\")}</p>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { TraceWaterfall, flatten, formatMs, type SpanKind, type TraceSpan, type TraceWaterfallProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/trace-waterfall.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "trace",
      "waterfall",
      "spans",
      "observability",
      "latency",
      "opentelemetry"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "spans is [{ id, parentId?, name, kind?: model | tool | retrieval | agent | other, start: ms, duration: ms, status?: ok | error, tokens?, detail? }]. total (ms), selected / defaultSelected / onSelectedChange, defaultCollapsed (ids). Spans are nested by parentId; a span whose parent is missing is top level. Exports flatten and formatMs.",
      "whenToUse": [
        "Seeing where an agent run spent its time, with nested model and tool calls",
        "Debugging latency or a failed step in a trace"
      ],
      "whenNotToUse": [
        "A plain list of steps; use trace",
        "Who handed work to whom; use agent-lanes",
        "Cost by step; use run-ledger"
      ],
      "composesWith": [
        "run-replay",
        "run-ledger",
        "agent-lanes",
        "token-meter"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Enters the tree once, on the first span (or the selected one)"
        },
        {
          "keys": "ArrowDown / ArrowUp",
          "action": "Next or previous visible span; Home and End go to the first and last"
        },
        {
          "keys": "ArrowRight",
          "action": "Opens a closed span, or moves to its first child"
        },
        {
          "keys": "ArrowLeft",
          "action": "Closes an open span, or moves to its parent"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the span and shows its timing in a polite live region"
        },
        {
          "keys": "Screen readers",
          "action": "A tree: each item has its level, position and expanded state and reads 'Search repo, tool call, 420 ms, starts at 1.2 s, failed'"
        }
      ],
      "customization": [
        "total",
        "defaultCollapsed",
        "Controlled selection",
        "kind colours use chart 1 to 5"
      ]
    },
    "statesNone": "A presentational trace: the spans are passed in, so it has nothing of its own to load or fail.",
    "examples": [
      "trace-waterfall-demo",
      "trace-waterfall-failed"
    ],
    "url": "https://ui.ballmac.com/components/trace-waterfall"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}