Run Summary
The receipt for one finished agent run — outcome and reason, a duration/token/cost stat strip, and an expandable per-model table that says so when its rows don't add up to the headline totals.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/run-summary.jsonPrompt
Build a React + TypeScript + Tailwind "RunSummary" component with zod and
lucide-react. It is the receipt for ONE agent run that has already finished.
Contract
- A zod schema (`runSummaryRunSchema` in a sibling contract file) is the single
source of truth: { id, label?, startedAt: string|number|Date, durationMs,
tokensIn, tokensOut, costUsd, costEstimated?, toolCalls, filesTouched?,
outcome, reason?, models: ModelUsage[] } where ModelUsage is { model, calls?,
tokensIn, tokensOut, cachedTokensIn?, costUsd? }.
- The outcome union is FOUR values, not two: "succeeded" | "partial" | "failed" |
"cancelled". `partial` exists because most agent runs end between "done" and
"broken" — 212 of 214 tool calls landed — and collapsing that into a green
check teaches the reader to distrust the check. `cancelled` exists because a
run the human stopped is not a failure, and a receipt that calls it one sends
people hunting for a bug they caused on purpose.
- Every optional field means "not reported", never zero. `filesTouched: 0` is a
run that wrote nothing; `filesTouched: undefined` is a provider that does not
track it — they must not render the same. Same for a model row's `calls` and
`costUsd`.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state, independent of `outcome`. `status="error"` means the
SUMMARY failed to load (retryable, offers a Retry); `outcome: "failed"` means
the RUN failed (not retryable from here). The two read differently on screen.
- Props: run: Run | null; status; defaultOpen?; stats? (which tiles, in order);
maxModelRows? (6); locale? ("en-US"); currency? ("USD"); timeZone? ("UTC");
copyable? (true); formatDuration?; formatStartedAt?; onRetry?; errorMessage?;
emptyState?; label? ("Run summary"); className plus the rest of the element
props, ref forwarded to the <article>.
Behavior
- THE CARD OWNS NO CLOCK. A run summary is a post-run artifact: it renders
`durationMs` and `startedAt` as data and never starts a timer, an interval or
an animation frame. Two renders a day apart of the same record produce the same
pixels, which is what makes it identical on the server, in a replay and in a
screenshot fixture. If you need a live elapsed time, you are looking at a
progress panel, not a receipt.
- RECONCILIATION IS A FIRST-CLASS STATE. Sum the model rows and compare them with
the headline totals. When tokens disagree, say so in the reader's units ("rows
account for 412,564 of 446,419 tokens — 33,855 aren't attributed to any
model"); when cost disagrees by more than half a cent, say by how much and in
which direction. A receipt whose parts do not add up must admit it — silently
showing both numbers is how people stop trusting the whole panel.
- The per-model table is COLLAPSED by default when the run reconciles and OPENS
ITSELF when it does not: that is the one case where the breakdown is not
optional detail but the explanation for a number the reader is about to doubt.
The disclosure header carries a quiet "doesn't add up" flag so the state is
visible while closed. `defaultOpen` pins either behaviour.
- The panel is UNMOUNTED when closed, not height-collapsed: a zero-height
collapse leaves every sort button reachable by Tab — an invisible keyboard
trap — and `aria-controls` is only set while open so it never points at an id
that is not in the document.
- Every column sorts (model, calls, in, out, cost, share) through a real button
inside the <th>, with `aria-sort` on the header. A new column opens in the
direction that answers the question you just asked: names A→Z, magnitudes
biggest-first. Ties break on ARRIVAL ORDER in a direction-independent way, so
toggling ascending/descending twice restores the exact original order. Rows
whose `calls` or `costUsd` is missing sort below zero instead of pretending to
be zero.
- Each row's colour is keyed to its position in the incoming array, never to its
position after sorting, so re-sorting never repaints a row the reader was
tracking.
- Truncation is announced. Only `maxModelRows` rows are drawn, above an explicit
"Show all 13 models" toggle and a "Showing 6 of 13" count — while the totals row
and the copied receipt keep covering ALL rows. A cap you can see is a cap you
can trust.
- Copy writes a plain-text digest of the WHOLE run — every model row, in arrival
order, regardless of the current sort or the visible slice — because what you
paste into a ticket has to be the whole receipt. The clipboard result is
visible in all three outcomes (idle / copied / failed): a silent no-op on a
blocked clipboard reads as a broken button. The reset timer lives in a ref and
is cleared both on unmount and before a new one is scheduled.
- Costs keep four decimals below a cent (a $0.0037 run must not round to
"$0.00"), two above it, and anything under $0.0001 renders as "less than
$0.0001". An unknown locale or a non-ISO currency code falls back to the
default instead of throwing: a receipt must not take the page down over a typo
in a prop. An unparseable `startedAt` renders as an em dash, never a RangeError.
- Identity changes reset the card (adjust state during render, no effect): a new
run id restores the default disclosure, the default sort and the row cap, so a
recycled card can never show the previous run's expanded state.
- Cache share is derived, not stored: sum `cachedTokensIn` across models and show
it as a share of the run's input ("55% read from cache") — that is the number
that explains why a 147k-token run cost $2.61.
- An empty `models` array is a real branch: say "per-model usage wasn't reported
for this run" instead of drawing an empty table. So is a partially costed
breakdown: if any row is missing `costUsd`, the cost total is withheld rather
than under-reporting the bill.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome, and
one tone table mapping outcome → theme variable — succeeded: var(--chart-2),
partial: var(--chart-4), failed: var(--destructive), cancelled:
var(--muted-foreground). The banner tints itself with
color-mix(in oklab, <tone> 7%, transparent) and the icon and outcome word take
the tone directly. Per-model rows cycle var(--chart-1..5). No hard-coded
colours anywhere.
- Four outcomes are four SHAPES — check / triangle / octagon / ban — so they
survive a monochrome theme and a colour-blind reader, and one persistent
sr-only role="status" line speaks the whole receipt as a sentence rather than
as eleven separate numbers.
- The stat strip is a <dl> in a `repeat(auto-fit, minmax(min(9rem,100%), 1fr))`
grid with a 1px gap over a bg-border parent: the gap draws the dividers, so
hairlines stay correct at every wrap point without a border that dangles at the
end of a row. Figures are tabular-nums.
- Long model ids and run ids use `wrap-anywhere` plus `min-w-0`, NOT
`break-words`: only overflow-wrap:anywhere lowers the element's min-content
width, which is what actually stops a 70-character gateway model id from making
the card wider than its column. The table itself keeps its own horizontal
scroll.
- Numbers, currency and the start time are formatted with a PINNED locale and
time zone by default. `undefined` would mean "whatever the runtime thinks",
which differs between the server and the browser and shows up as a hydration
mismatch on a date string.
- Motion is decorative only: a rotating chevron and a share-bar width transition,
both motion-reduce-guarded; the skeleton's pulse stops too, and every function
still works with animation off.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props, forwards its ref and exposes data-outcome for styling.
Customization levers
- Which tiles: `stats` picks the strip's contents AND their order — a sidebar can
ask for ["duration","cost"] only. An unknown key is ignored; an empty list
falls back to all six. A requested tile with no datum renders an em dash rather
than disappearing, so the layout never reflows behind the reader's back.
- Breakdown budget: `maxModelRows` decides how many rows are drawn before the
reveal control; raise it for a two-model run, lower it for a gateway sweep, but
never make it silent — the count is part of the reading.
- Disclosure: `defaultOpen` overrides the reconcile-driven default — true for a
debugging surface, false for a customer-facing receipt.
- Money and time: `currency` + `locale` swap the formatting wholesale;
`formatDuration` swaps the wording (a compact "4m51s", a bare seconds count);
`formatStartedAt` takes over the timestamp entirely (a relative "2 hours ago",
the viewer's own zone once you know it); `timeZone` alone is enough if you just
want a different pinned zone.
- Chrome: `label` names the region, `copyable` hides the copy affordance,
`emptyState` replaces the empty body, `errorMessage` + `onRetry` own the
envelope failure, and data-outcome lets you restyle a whole card per outcome
without touching the component.
- Tone: remap the four outcome colours in one table — that is the only place
colour is decided, and it is where a project with a real "warning" token should
point `partial`.Concepts
- A receipt, not a meter — every number is a fact the run already produced, so the component starts no timer and never derives a state from elapsed time. That is what lets the same record render identically on the server, in a replayed stream and in a screenshot; the moment a card needs a live number it has stopped being a summary and become a progress panel.
- Reconciliation as a visible state — the per-model rows are summed and checked against the headline totals, and a disagreement is named in the reader's own units (how many tokens went unattributed, how much money the rows are short by). The breakdown opens itself only in that case: detail is optional right up until it is the explanation for a number you are about to doubt.
- Four outcomes, four shapes — partial is not failure and cancelled is not failure. Painting either red trains people to ignore red, and hiding either behind a green check teaches them to distrust the check; each outcome gets its own icon shape so the distinction survives a monochrome theme.
- Announced truncation with surviving totals — only a few model rows are drawn, but the count withheld is on screen, the totals row still sums every row, and the copied receipt still contains every row in arrival order. A silent cap makes people debug the wrong thing; an announced one is just a smaller table.
- Sorting that never repaints — each row's colour is keyed to its position in the incoming array rather than its position on screen, and ties break on arrival order independently of direction, so flipping ascending and descending twice returns the table to exactly where it started.
- Missing is not zero — an absent per-model cost, an absent call count and an absent files-touched figure all render as an em dash and are excluded from totals, because a zero you cannot tell apart from "not reported" is how a receipt starts lying.
Agent Status
A compact agent state chip — a dot and a label morphing through idle, thinking, tool use, waiting-on-you and failure, with a drift-free phase clock and one polite announcement per phase.
Chat Checkpoint
A conversation save point that guards its own restore — named marker, live age, auto vs manual origin, an "N messages since" cost line, and a one-shot rollback that explains the fork before it happens.