AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Bot, Cpu, RefreshCcw, Wrench } from "lucide-react"

import { cn } from "@/lib/utils"
import type { AgentRosterAgent, AgentRosterData, AgentRosterState } from "./agent-roster.contract"

/**
 * State → colour, as a theme VARIABLE. The winner is published on the card as
 * `--agent-tone` and every child (dot, halo, ring) paints with that one variable,
 * so re-theming the whole roster is six lines here and nothing anywhere else.
 */
const TONE: Record<AgentRosterState, string> = {

Installation

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

Prompt

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 onSelect is 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's offsetTop is 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 %load is 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.

On This Page