# Trace Waterfall

> Span tree on a shared time axis for agent runs: nested agent, LLM, tool and retrieval spans, live bars, a details row and keyboard tree navigation.

- **Slug:** `trace-waterfall` · **Category:** agent · **Status:** beta
- **Import:** `import { TraceSpan, TraceSpanKind, TraceWaterfall } from "@kura-ui/react/trace-waterfall";`
- **Docs:** https://kuro.abstergo.fyi/components/trace-waterfall
- **Registry item:** https://kuro.abstergo.fyi/r/trace-waterfall.json
- **Tags:** agent, trace, observability, spans, waterfall, timeline, tokens, latency

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/trace-waterfall.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add trace-waterfall
# npm package
npm install @kura-ui/react
```

## When to use

- Show where the time (and tokens, and money) went in an agent run: which model calls, tool calls and subagents ran, nested, and when.
- Debug a slow or failed run: spot the long LLM call, the failed `pnpm test`, the subagent that ran in parallel.
- Watch a run live: running spans grow against the axis and a 'now' line moves (pass `startedAt` or drive `now`).
- Inspect one span's input, output, error, model, tokens and cost inline by selecting it.

## When not to use

- The agent's plan or todo list (what it intends to do) — use task-list.
- A narrative of steps with tool calls attached — use agent-timeline.
- Parallel subagents as a live roster with activity and progress — use agent-fleet.
- A single tool invocation — use tool-call.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `spans` | `readonly TraceSpan[]` | — | yes | Root spans (usually one agent span). TraceSpan = { id, name, kind: "agent" \| "llm" \| "tool" \| "retrieval", start (ms since run start), end? (omit while running), status: AgentStatus, meta? (mono hint: path, command), model?, tokens? { input, output }, cost? (USD), input?, output?, error?, children? }. |
| `title` | `React.ReactNode` | `"Trace"` | no | Header title. |
| `actions` | `React.ReactNode` | — | no | Controls on the right of the header (e.g. a replay or open-in-LangSmith button). |
| `now` | `number` | — | no | Current run time in ms since start (a controlled clock). Running spans end here and the 'now' line sits here. Use it for replays and server-driven clocks. |
| `startedAt` | `Date \| number` | — | no | Wall-clock start of the run. When given (and `now` isn't), running spans grow live, ticking every 250ms. Without either, running spans end at the latest known time. |
| `selectedId` | `string \| null` | — | no | Selected span (controlled). Its details row opens under it. |
| `defaultSelectedId` | `string \| null` | `null` | no | Initially selected span when uncontrolled. May name a span that doesn't exist yet; it opens when the span appears. |
| `onSelectedIdChange` | `(id: string \| null) => void` | — | no | Called when a span is selected, or with null when the selection is cleared. |
| `defaultCollapsedIds` | `readonly string[]` | — | no | Spans whose subtrees start collapsed (e.g. noisy subagents). |
| `renderDetails` | `(span: TraceSpan) => React.ReactNode` | — | no | Replace the details row body for a span (e.g. a CodeBlock with the diff a tool wrote). Return null to use the default stats + input/output. |
| `empty` | `React.ReactNode` | — | no | Rendered when there are no spans. Default: 'No spans yet'. |
| `label` | `string` | `"Trace"` | no | Accessible name of the tree. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **TraceWaterfall** (`data-slot="trace-waterfall"`) — Root panel, a container-query context. `data-state="running" | "idle"`; contains a polite status region.
- **Header** (`data-slot="trace-waterfall-header"`) — h-10 header: title, summary, actions.
- **Summary** (`data-slot="trace-waterfall-summary"`) — Mono: span count · total duration · tokens · cost. Count/tokens/cost hide on narrow containers.
- **Axis** (`data-slot="trace-waterfall-axis"`) — Sticky row with 'Span / Duration' over the tree and nice tick labels (0, 2m, 4m…) over the bars.
- **Body** (`data-slot="trace-waterfall-body"`) — Scroll container. Give the root a height (e.g. `h-[480px]`) to scroll long traces under the sticky axis.
- **Span** (`data-slot="trace-waterfall-span"`) — role="treeitem" with `data-kind`, `data-status`, `data-selected`; wraps the row and, when selected, its details.
- **Row** (`data-slot="trace-waterfall-row"`) — h-8 grid row: indented tree cell (chevron, kind icon, name, mono meta, glyph for running/failed/waiting, duration) and the bar cell.
- **Toggle** (`data-slot="trace-waterfall-toggle"`) — Chevron that collapses a subtree (pointer only; ArrowLeft/Right on the keyboard).
- **Bar** (`data-slot="trace-waterfall-bar"`) — Positioned on the shared axis. Neutral greys by kind (LLM darkest), thin for agent spans; blue while running, red failed, amber waiting, ink when selected.
- **Now** (`data-slot="trace-waterfall-now"`) — Hairline at the current time while any span is running.
- **Details** (`data-slot="trace-waterfall-details"`) — Sunken row under the selected span: kind, start, duration, model, tokens, cost, then input and output/error.
- **Duration** (`data-slot="trace-waterfall-duration"`) — Mono, tabular, width-reserved; ticks while the span runs.
- **Empty** (`data-slot="trace-waterfall-empty"`) — Rendered when there are no spans.

## Accessibility

- Role: `tree`
- A WAI-ARIA tree: each span is a treeitem with aria-level/posinset/setsize, aria-expanded for subtrees and aria-selected; one roving tab stop.
- Each treeitem is labelled “bash pnpm test, tool, 38.4s, failed” — status is spoken, never colour-only. The selected span is described by its details row.
- A polite status region announces failures and “Trace complete, 11m 52s” — ticking durations are not announced.
- The axis, grid lines, bars and now-line are decorative (aria-hidden or inside labelled items).
- Reduced motion: Bars and the now-line jump to their new positions instead of easing; the chevron turns instantly; rows and details only fade.

| Keys | Action |
| --- | --- |
| ArrowDown / ArrowUp | Moves focus to the next / previous visible span. |
| ArrowRight | Expands a collapsed span; on an expanded span moves to its first child. |
| ArrowLeft | Collapses an expanded span; otherwise moves to the parent span. |
| Home / End | First / last visible span. |
| Enter / Space | Selects the focused span (opens details); again to close. |
| Escape | Clears the selection. |

## Notes for agents

- Times are ms relative to the run start, not timestamps: convert with `span.start = +new Date(event.ts) - runStart`.
- Omit `end` (and set status running) for unfinished spans; pass `startedAt` (wall clock) or `now` so they grow.
- Put tokens/cost on leaf spans only. If every root span carries totals, the header uses the roots instead of summing.
- Ids must be unique across the whole tree; they drive selection, collapse state and focus.
- Give the root a fixed height for long traces; the axis stays sticky while the body scrolls.

## Examples

### A finished turn (minimal)

One agent span with two LLM calls and a tool call.

```tsx
import { TraceWaterfall } from "@kura-ui/react/trace-waterfall";

export function TraceWaterfallMinimal() {
  return (
    <TraceWaterfall
      className="w-full max-w-2xl"
      spans={[
        {
          id: "run",
          name: "Kura",
          kind: "agent",
          meta: "claude-opus-5-5",
          start: 0,
          end: 14_200,
          status: "done",
          children: [
            {
              id: "plan",
              name: "Plan the fix",
              kind: "llm",
              start: 0,
              end: 6_400,
              status: "done",
              tokens: { input: 12_400, output: 610 },
              cost: 0.06,
            },
            {
              id: "read",
              name: "read_file",
              kind: "tool",
              meta: "src/auth/client.ts",
              start: 6_500,
              end: 6_900,
              status: "done",
            },
            {
              id: "edit",
              name: "Write the patch",
              kind: "llm",
              start: 7_000,
              end: 14_200,
              status: "done",
              tokens: { input: 15_100, output: 1_240 },
              cost: 0.08,
            },
          ],
        },
      ]}
    />
  );
}
```

### Replay of a run (full)

The 12-minute auth-client migration replayed on a controlled clock: subagents, a failing pnpm test (selected, with its error), the fix, review and PR. The axis rescales as the run grows.

```tsx
"use client";

import { RotateCcw } from "lucide-react";
import * as React from "react";
import { IconButton } from "@kura-ui/react/icon-button";
import { type TraceSpan, TraceWaterfall } from "@kura-ui/react/trace-waterfall";

const RUN: TraceSpan = {
  id: "run",
  name: "Kura",
  kind: "agent",
  meta: "acme/web · kura/auth-client",
  start: 0,
  end: 11 * M + 52 * S,
  status: "done",
  children: [
    {
      id: "plan",
      name: "Plan the migration",
      kind: "llm",
      model: "claude-opus-5-5",
      start: 0,
      end: 9.8 * S,
      status: "done",
      tokens: { input: 18_400, output: 1_120 },
      cost: 0.11,
      input:
        "Migrate acme/web from the legacy REST session API to @acme/auth. Read the routes first.",
      output:
        "1. Read routes in src/app\n2. Map call sites of fetchUser()\n3. Patch src/auth/client.ts and src/middleware.ts\n4. Update tests, run pnpm test\n5. Open a PR",
    },
    {
      id: "routes",
      name: "read_file",
      kind: "tool",
      meta: "src/app/api/session/route.ts",
      start: 9.9 * S,
      end: 10.3 * S,
      status: "done",
      input: '{ "path": "src/app/api/session/route.ts" }',
      output: "export async function GET(req: Request) {\n  const user = await fetchUser(req)\n  …",
    },
    {
      id: "search",
      name: "search_code",
      kind: "retrieval",
      meta: "fetchUser(",
      start: 10.4 * S,
      end: 12.1 * S,
      status: "done",
      output: "14 matches in 9 files",
    },
    {
      id: "explorer",
      name: "explorer",
      kind: "agent",
      meta: "subagent",
      start: 12.5 * S,
      end: 62 * S,
      status: "done",
      children: [
        {
          id: "explorer-llm",
          name: "Find call sites",
          kind: "llm",
          model: "claude-haiku-4-5",
          start: 12.6 * S,
          end: 21 * S,
          status: "done",
          tokens: { input: 9_800, output: 420 },
          cost: 0.01,
        },
        {
          id: "explorer-grep",
          name: "bash",
          kind: "tool",
          meta: "rg -n 'fetchUser\\(' src",
          start: 21.1 * S,
          end: 21.9 * S,
          status: "done",
        },
        {
          id: "explorer-sum",
          name: "Summarize call sites",
          kind: "llm",
          model: "claude-haiku-4-5",
          start: 22.2 * S,
          end: 61.5 * S,
          status: "done",
          tokens: { input: 41_200, output: 2_300 },
          cost: 0.05,
        },
      ],
    },
    {
      id: "patch",
      name: "Draft the client patch",
      kind: "llm",
      model: "claude-opus-5-5",
      start: 63 * S,
      end: 1 * M + 40 * S,
      status: "done",
      tokens: { input: 52_300, output: 6_900 },
      cost: 0.31,
    },
    {
      id: "edit-client",
      name: "edit_file",
      kind: "tool",
      meta: "src/auth/client.ts",
      start: 1 * M + 40.2 * S,
      end: 1 * M + 40.9 * S,
      status: "done",
    },
    {
      id: "edit-mw",
      name: "edit_file",
      kind: "tool",
      meta: "src/middleware.ts",
      start: 2 * M + 8 * S,
      end: 2 * M + 8.6 * S,
      status: "done",
    },
    {
      id: "tests",
      name: "test-writer",
      kind: "agent",
      meta: "subagent",
      start: 2 * M + 12 * S,
      end: 5 * M + 28 * S,
      status: "done",
      children: [
        {
          id: "tests-llm",
          name: "Write session tests",
          kind: "llm",
          model: "claude-sonnet-4-5",
          start: 2 * M + 12 * S,
          end: 4 * M + 31 * S,
          status: "done",
          tokens: { input: 88_000, output: 9_400 },
          cost: 0.38,
        },
        {
          id: "tests-edit",
          name: "edit_file",
          kind: "tool",
          meta: "src/auth/login.test.ts",
          start: 4 * M + 32 * S,
          end: 4 * M + 33 * S,
          status: "done",
        },
      ],
    },
    {
      id: "test-1",
      name: "bash",
      kind: "tool",
      meta: "pnpm test",
      start: 5 * M + 31 * S,
      end: 6 * M + 9.4 * S,
      status: "failed",
      input: "pnpm test --filter @acme/web",
      error:
        "✕ login.test.ts › refreshes an expired session\n  Expected: 200\n  Received: 401\n\n  at src/auth/client.ts:88:11\n\nTests: 1 failed, 141 passed, 142 total",
    },
    {
      id: "diagnose",
      name: "Diagnose the failure",
      kind: "llm",
      model: "claude-opus-5-5",
      start: 6 * M + 10 * S,
      end: 7 * M + 1 * S,
      status: "done",
      tokens: { input: 71_500, output: 4_800 },
      cost: 0.29,
    },
    {
      id: "test-2",
      name: "bash",
      kind: "tool",
      meta: "pnpm test",
      start: 7 * M + 4 * S,
      end: 7 * M + 40.8 * S,
      status: "done",
      output: "Tests: 142 passed, 142 total",
    },
    {
      id: "review",
      name: "reviewer",
      kind: "agent",
      meta: "subagent",
      start: 7 * M + 42 * S,
      end: 10 * M + 48 * S,
      status: "done",
      children: [
        {
          id: "review-llm",
          name: "Review the diff",
          kind: "llm",
          model: "claude-opus-5-5",
          start: 7 * M + 42 * S,
          end: 10 * M + 48 * S,
          status: "done",
          tokens: { input: 104_000, output: 8_100 },
          cost: 0.42,
        },
      ],
    },
    {
      id: "pr",
      name: "bash",
      kind: "tool",
      meta: "gh pr create",
      start: 10 * M + 51 * S,
      end: 11 * M + 39 * S,
      status: "done",
      output: "https://github.com/acme/web/pull/482",
    },
    {
      id: "summary",
      name: "Summarize the run",
      kind: "llm",
      model: "claude-opus-5-5",
      start: 11 * M + 40 * S,
      end: 11 * M + 52 * S,
      status: "done",
      tokens: { input: 26_900, output: 4_960 },
      cost: 0.15,
    },
  ],
};

function traceAt(span: TraceSpan, t: number): TraceSpan | null {
  if (span.start > t) return null;
  const finished = span.end != null && span.end <= t;
  return {
    ...span,
    end: finished ? span.end : undefined,
    status: finished ? span.status : "running",
    output: finished ? span.output : undefined,
    error: finished ? span.error : undefined,
    tokens: finished ? span.tokens : undefined,
    cost: finished ? span.cost : undefined,
    children: span.children?.map((c) => traceAt(c, t)).filter((c): c is TraceSpan => c != null),
  };
}

const FROM = 4 * M + 40 * S;

const SPEED = 75;

const S = 1000;

const M = 60_000;

export function TraceWaterfallFull() {
  const [t, setT] = React.useState(FROM);
  const [run, setRun] = React.useState(0);
  const end = RUN.end ?? 0;

  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the trace
  React.useEffect(() => {
    const began = Date.now();
    setT(FROM);
    const id = setInterval(() => {
      const next = Math.min(end, FROM + (Date.now() - began) * SPEED);
      setT(next);
      if (next >= end) clearInterval(id);
    }, 120);
    return () => clearInterval(id);
  }, [run]);

  const root = traceAt(RUN, t);
  return (
    <TraceWaterfall
      className="w-full max-w-3xl"
      title="Trace"
      spans={root ? [root] : []}
      now={t}
      defaultSelectedId="test-1"
      defaultCollapsedIds={["explorer"]}
      actions={
        <IconButton
          label="Replay"
          icon={<RotateCcw />}
          variant="ghost"
          size="xs"
          className="-mr-1.5"
          onClick={() => setRun((r) => r + 1)}
        />
      }
    />
  );
}
```

### Live, from a wall clock (state)

A subagent that is still running: `startedAt` makes running bars grow in real time.

```tsx
"use client";

import * as React from "react";
import { TraceWaterfall } from "@kura-ui/react/trace-waterfall";

export function TraceWaterfallLive() {
  const [startedAt] = React.useState(() => Date.now() - 38_000);
  return (
    <TraceWaterfall
      className="w-full max-w-2xl"
      title="test-writer"
      startedAt={startedAt}
      spans={[
        {
          id: "tw",
          name: "test-writer",
          kind: "agent",
          meta: "subagent",
          start: 0,
          status: "running",
          children: [
            {
              id: "tw-read",
              name: "read_file",
              kind: "tool",
              meta: "src/auth/login.test.ts",
              start: 400,
              end: 900,
              status: "done",
            },
            {
              id: "tw-ctx",
              name: "search_code",
              kind: "retrieval",
              meta: "refreshSession",
              start: 1_000,
              end: 2_600,
              status: "done",
            },
            {
              id: "tw-llm",
              name: "Write session tests",
              kind: "llm",
              start: 2_800,
              end: 24_100,
              status: "done",
              tokens: { input: 31_200, output: 2_870 },
              cost: 0.14,
            },
            {
              id: "tw-approve",
              name: "edit_file",
              kind: "tool",
              meta: "src/auth/session.test.ts",
              start: 24_300,
              end: 25_000,
              status: "done",
            },
            {
              id: "tw-run",
              name: "bash",
              kind: "tool",
              meta: "pnpm vitest session",
              start: 25_200,
              status: "running",
            },
          ],
        },
      ]}
    />
  );
}
```

### Custom details (composition)

`renderDetails` shows the edit a tool made as a CodeBlock.

```tsx
import { CodeBlock } from "@kura-ui/react/code-block";
import { TraceWaterfall } from "@kura-ui/react/trace-waterfall";

export function TraceWaterfallCustomDetails() {
  return (
    <TraceWaterfall
      className="w-full max-w-2xl"
      title="Tool calls"
      defaultSelectedId="edit"
      spans={[
        {
          id: "turn",
          name: "Fix token refresh",
          kind: "agent",
          start: 0,
          end: 9_300,
          status: "done",
          children: [
            {
              id: "think",
              name: "Diagnose the failure",
              kind: "llm",
              start: 0,
              end: 7_900,
              status: "done",
              tokens: { input: 71_500, output: 4_800 },
              cost: 0.29,
            },
            {
              id: "edit",
              name: "edit_file",
              kind: "tool",
              meta: "src/auth/client.ts",
              start: 8_000,
              end: 8_400,
              status: "done",
            },
            {
              id: "test",
              name: "bash",
              kind: "tool",
              meta: "pnpm test login",
              start: 8_500,
              end: 9_300,
              status: "done",
            },
          ],
        },
      ]}
      renderDetails={(span) =>
        span.id === "edit" ? (
          <CodeBlock
            language="ts"
            filename="src/auth/client.ts"
            code={`if (res.status === 401 && session.refreshToken) {\n  session = await auth.refresh(session.refreshToken)\n  return retry(req, session)\n}`}
          />
        ) : null
      }
    />
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `kura-format`, `status-glyph`, `use-controllable-state`, `use-elapsed`, `utils`
- Related: `task-list`, `agent-fleet`, `run-header`, `tool-call`, `agent-timeline`
