Trace Timeline
Waterfall of steps / tool calls / LLM calls
Call LLM: draft answer detail
- Status
- Failed
- Started at
- 1.32s
- Duration
- 380ms
- Error
- Provider timed out after 30s
- Retried by
- Call LLM: draft answer — Succeeded
What it is
A waterfall view of an agent run's steps, tool calls, and LLM calls, positioned along a shared time axis instead of listed in a flat sequence. Each row expands in place to show the full detail of that call.
Why it matters
This is the view an engineer opens once a run has already gone wrong, and its entire value over a plain log is what a flat list hides: which calls actually overlapped. LangSmith, Vercel's AI SDK observability, and every agent debugger on the reference board place spans on a shared time axis rather than just an order, because "what happened, and when" is a question a bulleted step list can't answer.
Observed in: LangSmith · Vercel AI observability · Agent debuggers
Anatomy
- 1
trace-timelineRoot wrapper around the row list. - 2
trace-timeline-rowsThe list of spans, ordered by start time. - 3
trace-timeline-rowOne span: a Collapsible carrying its trigger and detail. - 4
trace-timeline-row-triggerThe clickable row header — status, name, bar, duration. - 5
trace-timeline-row-nameThe span's own name. - 6
trace-timeline-row-attempt"Attempt N" badge, shown once a span shares retry lineage. - 7
trace-timeline-row-status-textVisible status text for error/running rows — never colour alone. - 8
trace-timeline-row-trackThe decorative axis a row's bar is positioned on. - 9
trace-timeline-row-barPositioned by startMs/durationMs — the waterfall itself. - 10
trace-timeline-row-durationHow long the call took. - 11
trace-timeline-row-detailThe CollapsibleContent an open row expands into.
How to use it
Reach for it once a run has more than a couple of steps and someone needs to reconstruct what actually happened, not just what was attempted. Feed it a flat `spans` array with `startMs`/`durationMs` in milliseconds from the start of the run — it derives the shared axis itself, so never pre-stack or pre-offset spans before handing them in. When a step retries, add the retry as a second span whose `retryOf` points at the id of the one it retried; never mutate or remove the failed attempt to make room for it. Pass `renderDetail` to fill an expanded row with the real per-run detail (I/O, tokens, cost) once that surface exists on your side — omit it and a minimal built-in summary is used instead. `renderDetail(span, retriedBy)`'s second argument is already resolved for you: `undefined` for a span nothing retried, or `{ id, name, status }` of whichever span retried it — no need to scan `spans` for a matching `retryOf` yourself.
Do
Let a bar's position come from startMs/durationMs so two calls that actually overlapped visibly overlap.
Add a retry as a new span with retryOf set — the failed attempt keeps its own row, error text and all.
Don't
Don't lay every span out full-width in call order — that hides the one thing this view exists to show: concurrency.
- Search the web
- Read repo file
Don't drop the failed attempt once a retry succeeds — the record of what actually happened is the point.
Accessibility
- Keyboard
- One tab stop per span — the row trigger — plus whatever `renderDetail` puts inside the one open row. The bars, the axis and the duration are inert, so a forty-span trace is forty stops and nothing else.
- Enter and Space toggle a row. The trigger is a real `<button>` from Base UI's Collapsible, so that comes for free.
- Nothing else is bound. There is no arrow-key movement between rows, no Home/End, no Escape to collapse an open row and no expand-all — Tab is the only way through a long trace.
- Only one row can be open at a time, so opening a row closes whichever was open and removes that row's detail — and any tab stops inside it — from the page mid-traversal.
- Screen reader
- Rows are `<li>` inside a `<ul>`, so the trace announces its length and each row its position in it.
- Every visible part of a trigger is `aria-hidden` — the status icon, the kind icon, the name, the attempt badge, the status text, the bar and the duration. The whole accessible name is one explicit `aria-label`: `"<name>, Attempt <n>, <status>, <duration>"`, with a failed span reading `Failed: <error>`. It is built that way because two adjacent spans with no text node between them fuse into `"Attempt 2Succeeded"`.
- That name is also the only place a succeeded row says so. `trace-timeline-row-status-text` is rendered for `error` and `running` only, so on screen "succeeded" is carried by the tick icon and the bar colour alone.
- Where a bar sits on the shared axis is announced to nobody. The track is `aria-hidden`, and the row's name carries duration but not start time — so the one thing this view exists to show, that two calls overlapped, is visual only. Start time is spoken in the built-in fallback detail, and only for the row that is open.
- Base UI gives each trigger `aria-expanded` and a `data-panel-open` attribute; the open row's body is a `role="group"` named by a visually hidden `<h4>` reading "<name> detail".
- There is no live region anywhere in the component. A span moving from `running` to `ok`, a retry row appearing, a trace arriving after mount and the empty message being replaced are all silent. If your trace streams, announce it yourself.
- Durations are spoken through `formatDurationMs`, so "380ms" and "2.4s" rather than a raw millisecond count.
- Focus
- Expanding a row keeps focus on its trigger, which stays mounted — so the normal open/close path never strands focus.
- Changing `expandedId` from outside while focus is inside the open row's detail unmounts that detail and drops focus to `<body>`.
- Triggers ship `focus-visible:ring-2 ring-ring`. Anything `renderDetail` returns brings its own focus styling, or your global one.
- Nothing scrolls a newly opened row into view, so opening a row near the bottom of a long trace expands its detail below the fold.
Watch out for
- Deriving the timeline's total span from array order or index instead of startMs + durationMs — the moment a call starts after an earlier one has already finished, index-based layout stops matching reality.
- Treating an errored row as done once it's red. The visible "Failed: <reason>" text is the real signal — colour alone fails anyone who can't see it.
- Wiring
renderDetailto assume N5run-inspector's exact prop shape before that component exists — this component only ever hands back the row's ownTraceSpanplus its resolvedretriedBy; the richer per-run record N5 needs (I/O, tokens, cost, cache hit/miss) is still the host's to look up byspan.id. - Re-deriving "was this span retried, and how did it go" by scanning
spansfor a matchingretryOfat the call site —renderDetail's second argument already is that answer, resolved once by the component itself.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/trace-timeline.jsonRequires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.