AI

Agent Inbox

A human-in-the-loop queue of everything your agents parked on a person — triage ranking, overdue flags, one-shot inline Approve/Deny, grouping by agent or urgency, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  ArrowUpRight,
  Ban,
  Check,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  Filter,
  Hourglass,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "AgentInbox" component with zod and
lucide-react. No other runtime dependency; every control is hand-rolled so the
keyboard model stays one piece.

Contract
- A zod schema (`agentInboxItemSchema` in a sibling contract file) is the single
  source of truth for ONE parked request: { id, agentName, kind, title, preview,
  urgency, createdAt, state, unread?, impact?, resolutionNote? }.
- kind: "approval" | "question" | "review". It is not decoration — it decides
  which quick actions a row is allowed to grow (see Behavior).
- urgency: "critical" | "high" | "normal" | "low", ordered most → least severe.
  It is set by the PRODUCER (your policy engine) and never inferred from age: a
  four-hour-old "low" is still low, it is just old.
- state: "pending" | "approved" | "denied" | "answered" | "expired". Only
  "pending" carries buttons. The four terminal states stay in the list on
  purpose, and "expired" — nobody answered in time, the agent moved on without
  you — is the outcome a human-in-the-loop system most needs to be able to count.
- createdAt stays a raw ISO instant, not a pre-formatted label, because the
  queue has to RANK by it and because "waiting 41m" is the number that makes
  someone act.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  inbox's own render state, independent of any row's state. status="error" means
  the queue failed to load; a request being refused is state: "denied", and the
  two must read differently on screen.
- Props: items; status; now? (injected clock); groupBy? / defaultGroupBy? /
  onGroupByChange? (controlled or not); defaultPendingOnly? (false); sort?
  ("triage" | "newest" | "none", default "triage"); density? ("comfortable" |
  "compact"); staleAfterMs? (30 min); onDecide?(id, "approve" | "deny");
  onOpen?(id); renderDetail?(item); onRetry?; approveLabel? / denyLabel? /
  answerLabel?; formatAge?; emptyState?; errorMessage?; label? ("Agent inbox");
  showControls? (true); className plus the rest of the element props, ref
  forwarded to the <section>.

Behavior
- TRIAGE IS THE DEFAULT ORDER, and the rule is "what will hurt if ignored":
  waiting rows before settled ones, urgency descending, then OLDEST first among
  waiting rows (the one ignored longest is the one about to expire) but NEWEST
  first among settled ones ("what did I just decide"). sort="newest" is plain
  arrival order; sort="none" means your data layer already ranked it and the
  component must not second-guess that.
- THE KIND DECIDES THE ACTIONS. approval / review rows get Approve + Deny;
  a question row gets a single Answer that opens it, because sending an empty
  yes back to an agent that asked "which Stripe account?" is worse than making
  the human read it. A row whose handlers are missing grows NO buttons at all
  and shows its waiting word instead — a button that decides nothing is worse
  than no button.
- DECISIONS ARE ONE-SHOT. onDecide fires at most once per waiting request no
  matter how fast the button is clicked, because the lock lives in a ref read and
  written synchronously inside the click handler: five clicks dispatched in a
  single task all read the same stale state, so a state-only guard would let four
  through and approve a production deploy five times. The lock expires by itself
  — a generation counter ticks whenever the SET of waiting ids changes, so a
  genuine second request is answerable while a duplicate click is not.
- The answer is OPTIMISTIC BUT NOT AUTHORITATIVE: the row immediately reads
  "Approving…" with a spinner while both buttons go aria-disabled, and it stays
  that way until YOUR data flips item.state. The component never mutates the
  items it was handed — not the state, and not `unread`, which stays yours to
  decide (opened? scrolled? acknowledged on another device?).
- Buttons use aria-disabled, never the native `disabled` attribute: `disabled`
  blurs the button the instant it flips, dropping focus to <body> in the middle
  of the decision and removing it from the tab order.
- ANSWERING DESTROYS THE BUTTON YOU ANSWERED WITH. When your data settles the
  row, its quick actions unmount and focus falls to <body>, which loses a
  keyboard user's place in a queue they were working down. One effect, keyed to
  the set of waiting ids, notices that exact case — the row was decided AND
  focus is now on the body — and returns focus to the row itself. It is the only
  effect in the component; there is no timer, RAF, observer or listener to clean
  up anywhere.
- AGES COME FROM AN INJECTED CLOCK. Every age is `now - createdAt`, recomputed
  from the instant on every render and never accumulated, so a re-render, a
  paused tab or a replayed stream cannot drift and SSR and hydration agree byte
  for byte. Omit `now` and rows simply show no age. A waiting row older than
  staleAfterMs is called out as overdue in the row AND counted in the header, so
  the thing nobody answered cannot hide at position nine.
- KEYBOARD IS A LAYOUT GRID, not a list: role="grid" with role="row" /
  role="gridcell", one tab stop for the whole queue, ↑↓ between rows, ←→ into a
  row's quick actions, Home/End to the ends, and A / D to approve / deny the row
  you are on. The column index is CLAMPED when moving vertically, so stepping
  from a two-button waiting row onto a settled one lands on the row itself.
  Navigation reads its origin off the event target's data attribute rather than
  off focus state, so a click plus a keypress in the same task cannot navigate
  from the previous row — and keys are ignored entirely when the event comes
  from inside the detail slot, so typing "d" in your own textarea never denies.
- Grouping and filtering are VIEWS over the same array: groupBy "none" | "agent"
  | "urgency" (uncontrolled with a radiogroup in the header, or controlled by
  you), plus a "needs you only" toggle. Groups are built in first-appearance
  order over the already-sorted list, so they inherit the sort instead of
  fighting it. When the filter hides everything, the empty body says how many
  settled rows it hid and offers to show them — otherwise a full queue reads as
  an empty one and someone files a bug.
- The detail slot is optional and decides the row's affordance: with
  renderDetail the row is a disclosure (aria-expanded, aria-controls only while
  open, panel unmounted when closed so no invisible tab stops remain); without
  it the row is a plain button that hands the id to onOpen for your router.
  With neither, the row is not a button at all — it is a focusable cell.
- status="ready" with zero items renders the empty body rather than an empty
  frame, so a data layer that forgets to set "empty" still reads correctly.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + text-primary-foreground for Approve, bg-primary/10 + text-primary
  for the waiting count and the active filter, text-destructive for critical
  urgency, the overdue age, the expired outcome and the failure branch,
  bg-muted/60 for the impact line and bg-muted/20 for a settled row. No
  hard-coded colours anywhere, so a token swap redresses the whole queue and
  dark mode is free.
- Urgency and outcome are a SHAPE plus a WORD before they are a hue: triangle /
  up arrow / dash / down arrow for the four urgencies, check / ban / tick /
  stopped-timer for the four outcomes, each with its label — they survive a
  monochrome theme and a colour-blind reader. Unread is a dot AND a heavier
  title, plus an sr-only "Unread".
- The root is a <section aria-label> holding ONE persistent sr-only role=status
  line ("3 requests waiting, 1 longer than 30m") that also announces the
  decision in flight, so a change is spoken from an element the screen reader is
  already watching.
- Long asks and MCP-shaped agent names use `wrap-anywhere` (overflow-wrap:
  anywhere) plus `min-w-0`, NOT `break-words`: only the former lowers the
  element's min-content width, which is what actually stops a 96-character title
  from making the panel wider than its column. The preview is line-clamped
  because it is a producer-trimmed one-liner whose full text lives in the detail
  slot; nothing else is ever clipped.
- Motion is decoration only: the skeleton pulse, the chevron rotation and the
  decision spinner are all motion-reduce guarded, and the queue works with every
  animation off. The component starts no timer, RAF, observer or listener at all
  — the only clock is the `now` you pass.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Ranking: `sort` swaps triage for arrival order or defers to your own; extend
  the urgency union in the contract and the rank table together, and the sort,
  the badge and the grouping all follow.
- Attention budget: `staleAfterMs` decides when waiting becomes overdue — minutes
  for a trading desk, hours for a content pipeline. `formatAge` owns the wording
  ("41m", "41 minutes ago", a locale-aware relative formatter).
- Density and slots: `density="compact"` drops the preview and tightens rows for
  an operations wall; `renderDetail` adds the inline body (diff, payload, thread)
  and `impact` is where you spell out the blast radius above the button.
- Layout: `groupBy` / `defaultGroupBy` pick the axis, `showControls={false}`
  turns the panel into a read-only dashboard tile, `defaultPendingOnly` opens it
  already filtered to what needs a person.
- Wording: `approveLabel` / `denyLabel` / `answerLabel` ("Allow" / "Block" /
  "Reply"), `label` names the region, `emptyState` replaces the empty body,
  `errorMessage` + `onRetry` own the envelope failure.
- Scale: the component never scrolls itself — wrap it in your own scroll
  container or paginate upstream, so "N requests" always means N rows on screen.

Concepts

  • Triage is ranking by the cost of ignoring — waiting before settled, urgency descending, then the OLDEST waiting request first, because the one nobody has touched for 41 minutes is the one about to expire. Settled rows flip to newest-first: that half of the list answers "what did I just decide", not "what is rotting".
  • One-shot decision lock — the guard is a ref read and written synchronously inside the click handler, because five clicks dispatched in one task all observe the same stale state and a state-only guard would deploy to production five times. The lock is keyed to a generation that ticks whenever the set of waiting requests changes, so it expires by itself instead of leaving a dead button behind.
  • Optimistic, never authoritative — the row says "Approving…" the instant you click and keeps saying it until your data layer flips state. Nothing in the component rewrites the items it was handed, unread included: read state is a decision only your app can make, and a list that quietly marks things read on hover loses the thing you meant to come back to.
  • The kind decides the affordance — an approval has a yes/no gate, a question has an answer, a review has both a verdict and a body to read. Rendering Approve on a question would let someone send an empty yes to an agent asking which account to charge; rendering a button with no handler behind it is the same lie in a different place.
  • Overdue comes from an injected clock — the component starts no timer. Every age is recomputed as now − createdAt, so a paused tab, a re-render and a replayed stream all print the same string, and the count of requests past staleAfterMs is promoted into the header where a queue nobody answered cannot hide at position nine.
  • Grouping is a view, not a refetch — the same array regroups by agent or by urgency in first-appearance order over the already-sorted list, so groups inherit the ranking instead of fighting it. The "needs you only" filter is the same idea, and when it empties the list it says how many rows it hid and offers them back.
  • A queue is a layout grid, not a list — one tab stop for the whole inbox, ↑↓ between requests, ←→ into that row's quick actions, A and D to answer the row you are on. Keys are ignored when the event comes from inside your detail slot, so typing in your own textarea never denies a deploy.
  • Answering must not lose your place — the button you clicked is the first thing that disappears when a row settles, and a keyboard user who was working down the queue lands on <body>. The inbox catches that one case and hands focus back to the row, so the next ↓ continues where you were instead of at the top of the page.

On This Page