AI
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.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-status.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AgentStatus" component: a compact,
inline-sized indicator that says what an agent run is doing right now. No
dependencies beyond React, Tailwind and a cn() class merger.
Contract
- forwardRef<HTMLSpanElement>, extends React.HTMLAttributes<HTMLSpanElement>
minus children. The root is a <span> so it can sit inside a sentence.
- state: "idle" | "thinking" | "using-tool" | "waiting" | "error" (required).
- tool?: string — the tool being called. Also rendered next to the failure
label, so a broken run can name the call that broke.
- variant?: "inline" | "pill" (default "inline").
- labels?: Partial<{ idle, thinking, usingTool, unnamedTool, waiting, error }>.
usingTool is a PREFIX ("Using") and the tool name is a separate slot, so a
translation never has to interpolate; unnamedTool ("a tool") stands in when
state="using-tool" arrives without a name.
- showElapsed?: boolean (default false) — clock for the current phase.
- since?: number — epoch ms the current phase started at; overrides the internal
clock when the run began before this component mounted.
- minDwell?: number (default 0) — minimum ms a phase stays on screen.
- announce?: boolean (default true) — own a polite live region.
- pulse?: boolean (default true) — allow the active-state motion.
- Keep the five states as lookup tables (tone, label, "is active", "is timed"),
never as chains of ifs: adding a sixth state must be five edits and no new
branching.
Behavior — a phase is (state, tool)
- Identity is the PAIR, not the state alone: a second tool call is a new phase
even though `state` never changed, so the clock must restart and the dwell
lock must re-arm. Key it as `${state} ${tool ?? ""}`.
- Reset the phase epoch in a render-phase state adjustment (compare a stored
previous key with the current one, then set the new start) — doing it in an
effect leaves the first painted frame one phase behind.
Behavior — dwell latch (anti-flicker)
- minDwell > 0 latches the displayed phase: incoming phases are queued and the
latch commits after `minDwell - (now - phaseStart)` ms. Reschedule on every
change so the LAST phase of a burst wins; never drop one. Six 150ms tool calls
must settle on the sixth, not on nothing and not on the first.
- state === "error" bypasses the dwell entirely: a failure has to be visible the
moment it happens.
- minDwell === 0 must not go through the latch at all — derive the displayed
phase straight from props, so the default path costs zero extra renders. Keep
the latch mirror in sync while it is off, otherwise switching it on later
resurrects a stale phase.
- Clear the pending timeout on unmount and on every reschedule.
Behavior — the phase clock
- Show it only where "how long" is a real question: thinking, using-tool,
waiting. idle has nothing to time, and a fresh error epoch would count how long
the failure has been on screen, which means nothing.
- Never increment a counter. Store a `now` timestamp and re-derive
now - startedAt, so a throttled background tab, a long task or a dropped frame
cannot make the clock lose time.
- Self-schedule with setTimeout aligned to the phase's own second boundary,
1000 - ((now - startedAt) % 1000), instead of setInterval(1000): a fixed
interval drifts and eventually skips a whole displayed second.
- `now` starts as null and is filled by the first client tick, so the
server-rendered frame and the hydrating frame agree on "0:00" even when
`since` points into the past. Clamp negatives (a `since` in the future) to 0.
- Format m:ss, and h:mm:ss past the hour, with tabular-nums so the width never
jitters as digits change.
Behavior — announcements
- With announce on, render ONE permanently mounted sr-only <span role="status"
aria-live="polite" aria-atomic="true"> holding the phase sentence, and mark the
visible tree aria-hidden. A region that is inserted in the same frame as its
text is unreliable (several screen readers skip it), and leaving both trees
exposed reads the same status twice in browse mode.
- Derive the sentence from props — no effect, no timer, nothing to leak. It
contains no digits, so nothing re-announces once per second.
- The clock is aria-hidden even when announce={false}: a ticking number inside
anyone's live region interrupts the reader every second.
- announce={false} for lists and rosters — one region per row talks over itself;
let the surrounding container own a single region.
Rendering & styling
- One CSS variable carries the palette: the root sets --agent-tone from a
state → token table (idle var(--muted-foreground), thinking var(--chart-1),
using-tool var(--chart-4), waiting var(--chart-3), error var(--destructive))
and every child paints with var(--agent-tone). Merge the consumer's `style`
after it, so style={{ "--agent-tone": "var(--chart-2)" }} remaps one instance.
- Semantic tokens only: --muted-foreground, --chart-1/3/4, --destructive,
--card, text-foreground, text-muted-foreground, border. Tints are
color-mix(in oklab, var(--agent-tone) N%, var(--card) | transparent). No hex,
no rgb(), no oklch() in the component.
- Dot vocabulary, one glyph per state: idle = hollow ring; thinking = solid dot
under an animate-ping halo; using-tool = a smaller core inside a spinning arc
(border-t tinted, the rest a 25% color-mix); waiting and error = solid dot
inside a static ring.
- Motion belongs to the two active states only. Under prefers-reduced-motion the
halo is motion-reduce:hidden and the arc is motion-reduce:animate-none — the
arc stays visible and lopsided, so it still reads as "a tool is running", and
the dot, the color, the label and the clock all keep working.
- pill = rounded-full, a tinted border and a
color-mix(in oklab, var(--agent-tone) 10%, var(--card)) fill, with
text-foreground so contrast never depends on the tone. inline = no chrome, the
label inherits the surrounding text color.
- Layout: root inline-flex min-w-0 max-w-full align-middle; the state word is
shrink-0 whitespace-nowrap, the tool name is min-w-0 truncate with a title
attribute, the clock is shrink-0. A 40-character tool name ellipsises instead
of widening the header it sits in.
- Put data-state on the root so consumers can style or assert on the phase.
Customization levers
- Tone table: remap any state to another --chart-* token globally, or per
instance through style={{ "--agent-tone": ... }} — nothing else needs editing.
- New states: extend the union, then add one row to the tone table, one to the
labels record, and membership in the "active" and "timed" sets. No new
branching.
- Density: inline text-sm versus pill text-xs / px-2.5 / py-1; drop to
text-[0.6875rem] with px-2 for a dense toolbar, or raise the dot to size-3 for
a hero header.
- Motion strength: pulse={false} kills the halo and the arc but keeps the color
and the clock; swap animate-ping for a slower custom keyframe, or give
using-tool the halo too if you want one shared "busy" language.
- Dwell: 0 for raw truth (debugging a tool loop), 400-800ms for a calm header,
1200ms when the agent thrashes through many one-shot calls.
- Clock: showElapsed per surface; feed `since` from the server so a remount (a
virtualised list, a route change) keeps the reading instead of restarting it.
- Copy and i18n: everything visible comes from `labels`, and the tool name is
its own slot so no translation string has to interpolate it.
- Detail slot: give the tool name a bg-muted rounded chip, or drop it entirely
and keep the state word — the announcement sentence adapts on its own.
- Announcements: announce={false} in lists, or when a parent already owns a live
region for the whole run.Concepts
- Phase = state + tool — identity is the pair, so a second tool call counts as a new phase even though the state word never changed; that is what restarts the clock and re-arms the dwell lock instead of letting one long "Using…" blur two different calls.
- Dwell latch — a minimum time on screen turns a strobing agent into a readable one: incoming phases are queued rather than dropped, the last one in the window wins, and a failure jumps the queue because a broken run must never wait behind a cosmetic delay.
- Drift-free clock — the elapsed reading is re-derived from the phase epoch on every tick and the next tick is scheduled on the phase's own second boundary, so a throttled tab or a long task can delay a repaint but can never make the number skip or lose time.
- Active-only motion — the halo and the arc mean "tokens are burning right now"; waiting and error stay perfectly still so stillness itself carries meaning, and under
prefers-reduced-motionthe animation stops while the glyph, the color and the reading survive. - Quiet live region — one permanently mounted polite region holds a digit-free sentence and the visible chip is hidden from assistive tech, so each phase is announced exactly once instead of the clock interrupting the reader every second.
- One tone variable — the state picks a theme token, the root publishes it as
--agent-tone, and every dot, ring, border and tint mixes from that single variable; re-theming a state is one value, in one place, and dark mode comes free.
Task Queue
An agent work queue — live progress and the current step for running tasks, drag-to-reprioritise queued ones with a keyboard path, one-shot cancel and retry per row, a concurrency budget in the header, and four data states.
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.