Agent Roster
A multi-agent fleet panel — per-agent state dot, model chip, current-task line and capacity bar, attention-first sorting, a real listbox keyboard path, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-roster.jsonPrompt
Build a React + TypeScript + Tailwind "AgentRoster" component with zod and
lucide-react. It is the fleet view of a multi-agent workspace: one card per
agent, showing who it is, what it is doing right now, on which model, and how
full it is.
Contract
- A zod schema (`agentRosterAgentSchema` in a sibling contract file) is the
single source of truth for ONE agent: { id, name, role, model, state, tool?,
currentTask?, load, queued?, avatarUrl?, initials? }.
- `state` is SIX values, not three: "thinking" | "using-tool" | "waiting" |
"error" | "idle" | "offline". Each one changes behaviour, which is the test of
whether it deserves to exist: thinking/using-tool are the only animated ones
and the only ones counted as working; waiting/error are the ATTENTION states
that sort first and are the only ones announced; idle is deployed-but-free
while offline is paused — and an offline agent is excluded from the team load
average, because averaging in its empty capacity would flatter the fleet's
real headroom.
- The field is called `state`, not `status`, because the ENVELOPE status —
"loading" | "empty" | "error" | "ready" — is a different axis: "the roster
query failed" and "this agent's last run failed" must both be expressible at
the same time, and they read differently on screen.
- `load` is a FRACTION (0.62 = 62 % full), not a 0–100 integer, so a ratio you
already compute as running/limit drops straight in. Values above 1 are legal:
an over-subscribed worker fills the bar, turns destructive, and still prints
the true figure ("118 %") instead of silently clamping to a healthy 100 %.
NaN and negatives collapse to 0 rather than rendering "NaN%".
- Props: status; agents; variant?: "grid" | "list" ("grid"); sort?: "given" |
"attention" ("given"); onSelect?(agentId); selectedId?: string | null;
onRetry?; label? ("Agents"); showSummary? (true); showLoad? (true); pulse?
(true); announce? (true); skeletonCount? (6, clamped 1–12); emptyState?;
errorMessage?; labels? (copy overrides); formatLoad?; className plus the rest
of the div props, ref forwarded to the root.
Behavior
- ONE INTERACTION MODEL IS CHOSEN BY THE DATA, not by a flag: with `onSelect`
the roster is a real listbox (role=listbox, cards are role=option with
aria-selected) and gains a cursor, hover and focus ring; without it the same
cards render as a plain <ul>/<li> with no pointer affordance at all. An
element that looks clickable but isn't is worse than a plain one.
- The listbox is ONE tab stop with a roving tabindex — twelve agents must not
cost twelve Tabs. Arrow keys move the roving index, Home/End jump to the ends,
Enter or Space fires onSelect for the focused agent, and focus is moved
synchronously inside the key handler (the option is already in the DOM), never
from an effect, so no re-render can steal focus.
- ArrowUp/ArrowDown move by a MEASURED column count: the grid is
`auto-fill/minmax`, so the number of columns is decided by the container and
not by any breakpoint. Everything sharing the first card's offsetTop is the
first row; the count is read at keypress time, when layout has settled — no
ResizeObserver, nothing to clean up. In the list variant the step is simply 1.
- Type-ahead: printable keys accumulate into a buffer that expires 700 ms after
the last keystroke, matching agent names by prefix and cycling from the
current position. A repeated single character walks through matches; a longer
buffer re-matches from the current index so "re" cannot skip "Refund
Resolver". The expiry timer is the component's only timer and it is cleared on
unmount.
- The roving index is CLAMPED ON READ, never reset in an effect: when a poll
returns a shorter fleet, the index must not point past the end for even one
frame.
- `sort="attention"` ranks error > waiting > using-tool > thinking > idle >
offline with a STABLE sort, so agents inside one bucket keep the order your
data layer gave them and the list never shuffles on a re-render. A roster is a
triage surface: the two agents that need you must not sit below the fold
behind eleven happy ones.
- ONE polite live region for the whole roster, and it speaks only about
TRANSITIONS INTO an attention state ("Refund Resolver: Waiting for you").
Three rules make it usable: the first paint after mount (or after a non-ready
status) is never announced — a roster that shouts about every failure the
moment it appears is a roster people mute; a region per card would talk over
itself; and the previous fleet snapshot is compared DURING RENDER against a
key derived from the agents, not in an effect, so the announcement is part of
the first painted frame.
- Envelope branches are first-class, not `&&` afterthoughts: loading draws
skeleton cards in the same shape as the real ones with aria-busy; empty is a
sentence about a workspace where no agent has ever run; error is role=alert
with an optional Retry; ready renders the summary header plus the list. An
empty `agents` array under `status="ready"` falls into the empty branch rather
than rendering a header over nothing.
- The header summarises counts by state in a fixed order (never in data order,
which would make the numbers jump between polls) and prints the team load =
mean of the non-offline agents' load.
- A broken avatar never shows a broken image: it falls back to initials
(first letters of the first two words for Latin names, first two characters
for CJK ones, overridable per record). On a pre-rendered page a cached image
can fail BEFORE hydration attaches onError, so the ref callback also probes
`complete && naturalWidth === 0` synchronously. The avatar is keyed on its URL
so a data swap drops the stale failure state.
Rendering & styling
- Semantic tokens only. Each card publishes its state colour once as
`--agent-tone` (var(--chart-1) thinking, var(--chart-4) tool, var(--chart-3)
waiting, var(--destructive) error, var(--muted-foreground) idle/offline) and
every child — dot, halo, ring — paints from that one variable. Chrome is
bg-card / border / text-muted-foreground; selection is border-primary +
bg-primary/5. No hard-coded colours anywhere.
- Six states are SIX SHAPES, not six colours: pulsing disc (thinking), spinning
arc (tool), ringed disc (waiting/error), hollow disc (idle/offline) — each
next to its own word, so the roster survives a monochrome theme and a
colour-blind reader. Under prefers-reduced-motion the halo is hidden and the
arc stops, and the shapes still differ.
- The capacity bar is role=progressbar with aria-valuenow clamped to 0–100 and
an aria-valuetext that keeps the truth ("118% of capacity, over capacity").
Repeat the "over capacity" wording as sr-only text beside the figure: role=option
is children-presentational, so in the selectable variant a reader can prune the
bar's value and the red number would be the only marker left — and colour is
not a message. In the grid variant the bar sits under `mt-auto`, so bars line up
along the bottom edge of every card in a stretched row.
- Long text is handled by min-w-0 plus `wrap-anywhere` on the task line and
`truncate` on names, roles and identifiers — only overflow-wrap:anywhere
lowers an element's min-content width, which is what actually stops a
78-character MCP tool id from widening its grid track. The task line is
line-clamped to two lines with the full text kept in `title`.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Density: `variant="grid"` for a wall of cards, `variant="list"` for compact
rows in a sidebar; change the grid track (`minmax(min(15rem,100%),1fr)`) to
make cards wider or narrower — it is container-driven, so nothing else moves.
- Content: `showSummary` drops the counts header, `showLoad` drops the capacity
bars, `labels` rewrites every word (including the "Using" prefix and the
"a tool" fallback) for another language or another domain vocabulary.
- Motion: `pulse={false}` stills the whole roster without removing information,
because the state is a shape plus a word, not an animation.
- Ordering: `sort="attention"` for an ops surface, `sort="given"` when your API
already ranks the fleet.
- Units: `formatLoad` swaps the wording — "3/4 runs", a token budget, a queue
depth. It also formats the team figure in the header, so both stay consistent.
- Selection: `onSelect` + `selectedId` are the whole click-through story; keep
the selected agent in your own state and open a detail panel, a drawer or a
route beside the roster.
- Envelope: `emptyState` replaces the empty body, `errorMessage` + `onRetry`
own the failure branch, `skeletonCount` matches the placeholder count to the
fleet size you expect.Concepts
- Roster, not graph, not queue — a roster is organised by WHO: one row per agent, permanent, present even when the agent is doing nothing. A topology view is organised by edges (who hands work to whom) and a request queue is organised by pending decisions (what needs a human next). Choosing the wrong one shows up immediately: an idle agent has no place in a queue, and a roster has no edges to draw.
- Attention-first sorting — failures and approval waits float to the top with a stable sort, so the two agents that need you are never below the fold behind eleven happy ones, and the agents inside one bucket never shuffle between polls. It is a display order, not a filter: nothing disappears, so the fleet count stays honest.
- The interaction model follows the data — passing
onSelectis what turns cards into listbox options with a cursor, a hover and a focus ring. Without a handler the cards are plainly non-interactive, because a hover animation on something that ignores your click is a lie the reader only discovers by clicking. - Roving tabindex over a measured grid — the roster is one tab stop and the arrows move inside it. Since the column count is decided by the container (
auto-fill), the only honest way to make ArrowDown mean "one row down" is to measure it: everything sharing the first card'soffsetTopis row one, read at keypress time when layout has already settled. - Announce transitions, not inventories — one polite live region for the whole panel, speaking only when an agent ENTERS an attention state, and never on the first paint. A live region per card talks over itself, and a region that reads the whole fleet on arrival gets muted before it ever says anything useful.
- Capacity that can exceed 100 % —
loadis a fraction of the agent's own concurrency budget, and an over-subscribed worker keeps its true figure while the bar sits full and destructive-toned. Clamping the number as well as the bar would hide exactly the condition you built the bar to catch; offline agents are left out of the team average for the same reason.
Agent Handoff
An agent-to-agent handoff marker — from/to avatars, a track that marches while the transfer is in flight, the reason control moved, chips for the context passed along, and who holds the run; compact divider and detailed card variants.
Agent Graph
A workflow DAG drawn in pure SVG from nodes and edges alone — layered auto-layout with crossing reduction, typed node shapes, status tints, flowing active wires, loop-back routing and four data states.