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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * States
 *
 * The five states an agent run can be in from the reader's point of view: it is
 * doing nothing, it is reasoning, it is calling a named tool, it needs a human,
 * or it broke. Everything else in this file is a table keyed by that union —
 * adding a sixth state is five edits and no new branching.
 * -------------------------------------------------------------------------- */

Installation

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

Prompt

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-motion the 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.

On This Page