Agent Steps
A live agent run whose plan is still being written — steps stream in without moving what is already on screen, failures retry in place, and a replan cancels the rest of the plan instead of failing it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-steps.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AgentSteps" component (zod, lucide-react).
It shows an agent working through a plan it is STILL WRITING: steps arrive mid-run,
can fail and be retried, can be skipped, and can be cancelled when the agent
replaces the rest of its plan.
Contract
- A zod schema (`agentStepSchema` in a sibling contract file) is the single source
of truth for one step: { id, title, detail?, status, startedAt?, endedAt?,
summary?, output?, error?, retryCount?, cancelledReason?, planRevision? }.
- `status` is a six-value enum and the six are NOT interchangeable:
pending ("Queued") · running · done · failed · skipped · cancelled.
failed = it ran and broke, and can be retried. cancelled = the agent revised
its plan and this step will never run. skipped = the agent looked at it and
decided it was unnecessary. Collapsing cancelled into failed makes an ordinary
replan read as a crash, which is the single worst thing this component can do.
- `startedAt` / `endedAt` are absolute instants (ISO 8601 with an offset), never
durations. A separate `agentStepsStatusSchema` — "loading" | "empty" | "error" |
"ready" — is the panel's own render state.
- Props: items; status; now? (ISO | epoch ms | Date — pin it and the component
never reads the wall clock); tickMs? (default 1000, clamped to >= 100, 0 freezes
the readout and creates no interval); stallAfterMs? (default 30000, 0 disables);
onRetryStep?(id); onReload?(); label?; skeletonRows? (clamped 1-12); emptyState?;
className; plus the rest spread onto the root div. forwardRef to that div.
- Affordances are handler-gated: no onRetryStep means failed steps have no Retry
button at all, no onReload means the error branch has no button. Nothing dead
ever renders.
Behavior
- Append-only. New steps go on the end, and NOTHING above the list may change
height when they do — a reader half-way through step 2's output must not have it
move under them. That means the header is a fixed-height single line with
`truncate` and `tabular-nums` (a header that lists the plan rewraps and pushes
the whole list down; measured 20px of jump), and there is no auto-scroll: the
transcript that hosts this panel owns scrolling.
- Nothing the component renders about one step depends on another step's status.
A pipeline timeline can grey out everything behind a failure because that run is
over; an agent may retry or replan, so queued steps stay Queued. This is also
what makes retry isolation hold: retrying step 3 changes step 3's status byte and
nothing else's.
- Retry: `onRetryStep(id)` is a request, not a mutation — the consumer sets that
step back to pending and increments retryCount. Any retryCount above 0 renders as
a visible "N retries" badge in every later state, so a step that only landed on
its third attempt still says so when the run is over.
- Replan: the consumer keeps every settled step, flips the ones that never ran to
cancelled (with a cancelledReason), and appends the new tail with a higher
planRevision. A step whose revision is higher than its predecessor's opens a
"Plan revised" band — rendered INSIDE that step's own listitem, so the list stays
exactly one listitem per step.
- Elapsed time is always derived as `now - startedAt`, never accumulated by a
counter. Settled steps use endedAt - startedAt (a tenth of a second under 10s,
whole seconds above). A running step reads the wall clock through
useSyncExternalStore with the snapshot floored to the tick, so the value is
cacheable and the digits move once per tick. Subscribe to visibilitychange as
well as the interval: a suspended tab freezes timers but not the clock, and the
difference between the two designs is 40s versus 0s on the first frame back
(measured, with Date.now faked forward and setInterval frozen).
- Stall: a step running longer than stallAfterMs gets an inline "Still running
after Ns - no result yet." note, placed LAST in the row so it can only push the
steps below it, never the output above it.
- Announcements go through ONE polite live region on a ~700ms trailing debounce.
Appending queued steps says nothing at all; a settled step, a stall and a settled
run each produce one sentence, and an unchanged sentence is never re-sent. The
first paint is a baseline, not news. Measured: 30 streamed steps produce 1 live
region update, versus 29 for the announce-everything version. The elapsed seconds
deliberately live outside the region — "41s... 42s..." makes a screen reader
unusable.
- Output: collapsed by default behind a disclosure that names the size ("Show
output · 500 lines"). Expanded it is a labelled, focusable (tabIndex 0) scroll
container with `max-h-80 overflow-auto` — every line is in the DOM, nothing is
ellipsised, and a real wheel gesture scrolls it (`overflow:hidden` would still
accept a programmatic scrollTop, so verify with a wheel, not an assignment).
A failure's `error` is NOT behind a disclosure: an error you have to click to
read is an error you miss.
- Duplicate ids are dropped, first occurrence wins (they would collide on React
keys and on aria-controls, and one disclosure would toggle its twin).
Unparseable instants simply produce no duration; endedAt before startedAt clamps
to 0.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground shell, bg-primary +
text-primary-foreground for done, bg-destructive + text-background for failed
(text-destructive-foreground does not exist in this theme and would be silently
dropped), bg-muted for skipped, dashed border-muted-foreground/40 for cancelled
and pending, border-destructive/40 + bg-destructive/5 for the error panel,
bg-muted/40 for the output panel, focus-visible:ring-2 ring-ring on every
control. cn() merges the consumer className onto the root.
- Status is never carried by hue alone: empty ring = queued, spinner = running,
check = done, cross = failed, dash = skipped, slashed dashed ring plus a struck
through title = cancelled, and the word itself is always printed next to the
step. The connector below a step is tinted by that step, and never claims the
next one ran.
- `ol role="list"` with an aria-label; every row an explicit `role="listitem"`
(both roles are explicit because Tailwind's list-style reset drops them in
Safari/VoiceOver); the running row carries aria-current="step". The disclosure
is aria-expanded + aria-controls, the panel aria-labelledby the trigger.
- Long unbroken tokens use `wrap-anywhere` with `min-w-0`, not `break-words`,
which does not shrink min-content and will burst the row at 375px.
- Only the spinner and the chevron animate, both with motion-reduce:*-none; with
motion off the disclosure still works.
Customization levers
- Vocabulary: STATUS_TEXT is one object — rename Queued/Running/Done/Failed/
Skipped/Cancelled to your domain, but keep six distinct words. Merging any two
of them is a semantic loss, not a simplification.
- Density: the row is `pb-4` with a size-6 node; drop to pb-2 and size-5 for a
compact inline variant, or remove the header entirely when the panel sits inside
a message bubble that already has a title.
- Output surface: raise or drop `max-h-80`, or swap the `pre` for a syntax
highlighter / diff view / table — keep the scroll container and the line count in
the trigger, and never add a character cap.
- Clock: pin `now` for tests and SSR, set tickMs to 5000 for long runs (or 0 to
freeze the readout entirely), and tune stallAfterMs per surface — 30s for a
coding agent, a few minutes for a batch job.
- Chrome: `label` names the run, `emptyState` replaces the whole zero-state block,
`skeletonRows` should match the plan size you typically get back, and
`planRevision` can be dropped entirely if your agent never replans.Concepts
- Append-only stream — the plan grows downward and only downward. The header is a fixed-height single line and there is no auto-scroll, so the anchor a reader is on (an expanded output, a summary they are half-way through) keeps its exact position while steps 8, 9 and 10 arrive. Measured shift on append: 0.00px; a header that lists the plan instead of counting it moves everything 20px.
- Cancelled is not failed — when an agent throws away the rest of its plan, the dropped steps never ran. They get a dashed ring, a struck-through title and their own reason line; failures keep the cross, the destructive tint and the retry button. Two words, two shapes, two meanings — a replan should not look like a crash.
- Retry isolation — nothing in a step's appearance is derived from another step's status, so retrying one step moves exactly one status byte. The tempting shortcut (grey out everything behind a failure, the way a deploy timeline does) silently rewrites two other rows the moment you retry.
- Derived elapsed — the panel is given absolute instants and subtracts them from an injected or observed
now. A counter that adds a second per tick is wrong the moment the tab sleeps; a subtraction is right the instant it is read again, which is why the clock also listens forvisibilitychange. - Stall as a state — "no news" is rendered as news. Past
stallAfterMsthe running step says how long it has been silent, so a reader can tell a slow step from a hung one without timing it themselves. - Coalesced announcements — a live region that fires on every change is a live region users switch off. Only a settled step, a newly stalled step and a settled run are announced, on a trailing debounce, and identical sentences are never repeated: thirty streamed steps produce one utterance.
Artifact Panel
A side panel for the file the assistant keeps rewriting — a revision rail with the prompt behind each one, preview / source / changes, per-revision scroll memory, and copy plus download that always take the revision you are looking at.
Context Attachments
The context list hanging off a conversation — files, links, snippets and selections with their token cost, under a budget meter that names exactly which entries get dropped when the window overflows.