Agent Trajectory
A four-state agent-run timeline — every step a measured segment on the run's own clock, coloured by kind, retries nested inside the step they re-attempt, tokens generated per step on a second track, and a wall-time-versus-generation mix apportioned by largest remainder so the shares add to exactly 100.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-agent-trajectory.jsonPrompt
Build a React + TypeScript + Tailwind "ChartAgentTrajectory" card — one agent
run drawn on a measured time axis — as positioned HTML boxes over a zod
contract. Not recharts and not SVG: the picture is percentage-width boxes on a
shared clock, so CSS can do the layout, ellipsis can do the eliding, and each
box can carry role="option".
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
meta: { runId; task; model; contextTokens?; outputPricePerMTokUsd?;
ttftMs? };
steps: { id; kind: "think" | "tool" | "retry" | "error" | "final";
label; startMs; endMs; tokens?; parentId?;
status: "ok" | "failed" | "cancelled"; detail? }[] }
- startMs / endMs are OFFSETS ON THE RUN'S OWN CLOCK in milliseconds, never
timestamps: relative numbers render the same pixel on the server and in every
visitor's timezone. The component never reads a clock.
- kind is what the work was; status is how it ended. Keep them apart: a step
that failed keeps its own kind, and "error" means recovery work (reading a
traceback, repairing tool arguments) which burns tokens and buys no progress.
- tokens are GENERATED tokens only. A tool call generates none, which is what
makes "a third of the clock, none of the tokens" readable.
- Props = z.infer of the schema plus title, defaultBasis, showTokenTrack,
locale, onStepSelect, onRetry, emptyState, className. No parallel interface.
Behavior
- Four first-class branches in one bg-card panel: loading (a deterministic,
aria-hidden skeleton of both tracks), empty (says it needs measured offsets,
and counts the rows that arrived without them), error (message plus a retry
button only when onRetry exists), ready. A ready payload with no drawable
step, or one whose steps all sit on the same instant, renders the empty
branch rather than a chart with no axis.
- Time attribution is first-claim, clipped to the parent, and it is the spine
of the card:
* steps are walked in start order; a step claims from where the last claim
ended to its own end, so two overlapping steps never bill the same ms twice;
* inside a step, its attempts (children by parentId) claim in start order,
clipped to the step's window; whatever they leave — backoff, the wait
before the first attempt — stays with the step's own kind;
* what no step claimed is a sixth bucket, "between steps".
So sum(kinds) + gap === wall clock exactly, for any payload.
- Mix bar: those six buckets apportioned by LARGEST REMAINDER — floor each
share, hand the leftover points to the largest fractional parts, ties to the
bigger quantity then the earlier bucket. Rounding each share on its own is
how a mix prints 101. A bucket holding nothing keeps a hard 0. A radiogroup
switches the basis between wall time and generated tokens; both bases add to
exactly 100 — and a basis holding nothing (a run of pure tool calls read as
tokens) says so instead of claiming a total it cannot show.
- Headline reads, all derived, never passed in: wall clock; time inside
token-generating steps versus everything else — the two are one partition of
one clock, so round one share and subtract for the other rather than letting
12.25 / 87.75 print as 100.1%; total tokens with cost from
outputPricePerMTokUsd; decode throughput measured NET of ttftMs × the number
of model steps; the longest step; and the retry tax — for every re-attempted
step, its window minus its last landing attempt, i.e. the time that bought
nothing.
- One tab stop drives the reading: role="listbox" with aria-activedescendant,
one role="option" per step and per attempt in start order. Arrows walk,
Home/End jump, Enter/Space and a click fire onStepSelect. Keyboard moves push
a sentence into an sr-only live region; pointer moves do not. Every number is
repeated in an sr-only table.
- Nothing is dropped in silence: rows without finite offsets, repeated ids, a
parentId nobody sent or one that loops, nesting deeper than one level, an end
before its start, and windows overlapping one already counted are each
counted and printed under the chart.
Rendering & styling
- Semantic tokens only. Kind → ink: think var(--chart-1), tool var(--chart-2),
retry var(--chart-3), final var(--chart-4), error var(--destructive) — the
one kind a reader should find without a legend. Colour is the ONLY thing kind
controls; status takes a dashed border in the same ink plus a glyph, so the
two encodings never fight and the card survives greyscale.
- A segment is a wash (the ink at opacity-25 / dark:opacity-35) under a solid
2px top rule in the full ink, with the text on top in the theme's own
foreground — never white on yellow, and a 3px sliver still has a colour.
- Positioning: left and width are percentages of the run clock, derived from
the same startMs / endMs the label prints, with a 3px painted floor. The
token bar sits at the same left and width; its height is the count. A label
is printed inside a segment only when it actually fits: a ResizeObserver
measures the plot, the label's own width is estimated from its length at
text-[11px], and the widest rung that fits is drawn — name plus duration,
name, duration, nothing. Never elide to fill the box: "shell.…" names
nothing, and everything dropped is still in the readout and the sr-only
table.
- Panel: rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
className; the root spreads the remaining props. The observer is disconnected
on unmount and on node change; no timers, no RAF. The skeleton's pulse is
motion-reduce:animate-none.
Customization levers
- Density: the lane heights (h-9 step lane, h-5 attempt lane, 48px token track)
are three constants; halve them for a sidebar, and raise TICK_PITCH_PX to
thin the axis out with them.
- Drop a track: showTokenTrack={false} leaves the step lane and every headline
intact; the attempt lane hides itself when no step has children.
- Palette: KIND_INK is a five-entry map. Re-map it to your own chart tokens,
or point two kinds at one token when you want think and error to read as one
family — the legend, the segments, the token bars and the readout dot all
read that single map.
- Label fitting: LABEL_CHAR_PX (≈0.55em at text-[11px]), SEGMENT_PAD_PX and
GLYPH_PX are the whole width estimate; raise LABEL_CHAR_PX for a wider
typeface or a CJK label set, and reorder the rungs inside segmentLabel() when
a name matters more to you than a duration.
- Mix basis: defaultBasis picks the opening apportionment; drop the radiogroup
entirely if your run has no token counts, and the token bucket rows go to 0
rather than disappearing.
- Money and units: omit meta.outputPricePerMTokUsd for a card with no cost, and
meta.ttftMs for throughput reported over the whole step (the copy follows).
- Selection: onStepSelect is yours — open a transcript, scroll a log, filter a
table. Nothing is pinned inside the component.Concepts
- Measured axis, not a status list — the segment's left, its width, its printed duration and its share of the run all come from one pair of numbers, so a step cannot be drawn in one place and described from another. A run whose steps carry no offsets is the empty branch, not a chart with a made-up scale.
- First-claim attribution — every millisecond of the run is credited to exactly one kind: steps claim in start order, attempts claim inside their parent, and what nobody claimed becomes the "between steps" bucket. That is what lets the mix bar be an apportionment of a whole instead of six ratios that happen to land near 100.
- Largest remainder — floor every share, then hand the leftover points to the largest fractional parts. This run's exact shares are 21.0, 34.8, 12.7, 10.0, 20.0 and 1.5; rounding each on its own prints 101, and a card that cannot add up is a card nobody trusts.
- Retry tax — a re-attempted step's whole window minus its last landing attempt: the failed attempts plus their backoff, the part of the clock that bought nothing. It is deliberately not the same number as the "retry" colour's share, because the first attempt and the backoff are not retries.
- Wall time versus generation time — the time inside token-generating steps against everything else, with decode throughput measured net of the time to first token each model step paid. It is the read an agent engineer actually wants: whether to buy a faster model or a faster sandbox.
- Colour is kind, shape is outcome — kind owns the ink, status owns the dashed edge and the glyph. Two encodings on one mark that never contend, and a picture that still parses in greyscale or with the third of readers who would otherwise merge two of these hues.
Drift Monitor
A four-state drift board: one banded strip per feature on a shared PSI scale with the warn and alert rules drawn across every row, the prediction-drift lane on top, and the worst offender named — pick any window from the tick row to read the whole card at that moment.
Data Mixture
A four-state training-mixture card: one composition bar per training stage split by data domain, with per-domain epochs so upsampling reads as the gap between unique tokens and tokens seen.