Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  AGENT_TRAJECTORY_KIND_LABELS,
  AGENT_TRAJECTORY_KINDS,
  type ChartAgentTrajectoryData,
  type ChartAgentTrajectoryKind,
  type ChartAgentTrajectoryStep,
  type ChartAgentTrajectoryStepStatus,
} from "./chart-agent-trajectory.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-agent-trajectory.json

Prompt

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.

On This Page