{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-2",
  "title": "Agent 2: approvals inbox",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "One inbox for every action AI agents are waiting on: requests with their risk, the full request beside the list, Approve and Deny with A and D, J and K to move, and approve all low-risk requests at once.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/approval-card.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/kbd.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/agent-2/agent-2.tsx",
      "content": "// Ballmac UI: Agent 2. https://ui.ballmac.com/blocks/agent-2\n\"use client\"\n\nimport * as React from \"react\"\nimport { Bot, CheckCheck, Inbox, OctagonAlert, ShieldAlert, ShieldCheck } from \"lucide-react\"\n\nimport { ApprovalCard, type ApprovalDetail, type ApprovalRisk, type ApprovalStatus } from \"@/components/ballmac/approval-card\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Kbd } from \"@/components/ballmac/kbd\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Agent2Request = {\n  id: string\n  /** The agent asking. */\n  agent: string\n  /** What it wants to do, as a short sentence. */\n  title: string\n  /** Why, in one or two sentences. */\n  description?: string\n  risk: ApprovalRisk\n  details?: ApprovalDetail[]\n  /** The exact thing that will run: a diff, a command, an email draft. */\n  preview?: React.ReactNode\n  /** When it was asked, already written for people (\"4 min ago\"). */\n  requested: string\n  /** The task the agent is working on. */\n  task?: string\n  status: ApprovalStatus\n}\n\ntype Agent2Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    /** The requests, newest first. */\n    requests?: Agent2Request[]\n    /** Heading. */\n    title?: string\n    /** Called when a request is approved. The block records the decision straight away. */\n    onApprove?: (id: string) => void\n    /** Called when a request is denied. */\n    onDeny?: (id: string) => void\n  }\n\nconst sample: Agent2Request[] = [\n  {\n    id: \"r1\",\n    agent: \"Billing agent\",\n    title: \"Refund $129.00 to jordan@acme.com\",\n    description: \"The second charge on order 4821 is a duplicate made 40 seconds after the first.\",\n    risk: \"high\",\n    details: [\n      { label: \"Charge\", value: \"ch_2\" },\n      { label: \"Amount\", value: \"$129.00\" },\n    ],\n    requested: \"2 min ago\",\n    task: \"Resolve ticket 5512\",\n    status: \"pending\",\n  },\n  {\n    id: \"r2\",\n    agent: \"Docs agent\",\n    title: \"Publish the updated install guide\",\n    description: \"Replaces the Node 20 steps with Node 22 and adds the pnpm setup.\",\n    risk: \"medium\",\n    preview: (\n      <pre dir=\"ltr\" className=\"p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap\">\n        {\"- Requires Node.js 20 or later\\n+ Requires Node.js 22 or later\\n+ Run `corepack enable` to get pnpm\"}\n      </pre>\n    ),\n    requested: \"9 min ago\",\n    task: \"Keep the docs current\",\n    status: \"pending\",\n  },\n  {\n    id: \"r3\",\n    agent: \"Research agent\",\n    title: \"Read 14 pages on competitor pricing\",\n    description: \"Only reads public pages. Nothing is stored outside this workspace.\",\n    risk: \"low\",\n    requested: \"12 min ago\",\n    task: \"Pricing review for Q4\",\n    status: \"pending\",\n  },\n  {\n    id: \"r4\",\n    agent: \"Research agent\",\n    title: \"Search the help center for “export”\",\n    risk: \"low\",\n    requested: \"15 min ago\",\n    task: \"Pricing review for Q4\",\n    status: \"pending\",\n  },\n  {\n    id: \"r5\",\n    agent: \"Ops agent\",\n    title: \"Restart the worker service in production\",\n    risk: \"high\",\n    requested: \"1 hr ago\",\n    status: \"approved\",\n  },\n]\n\nconst riskIcon = { low: ShieldCheck, medium: ShieldAlert, high: OctagonAlert } as const\nconst riskTone = { low: \"text-chart-2\", medium: \"text-chart-3\", high: \"text-destructive\" } as const\nconst riskWord = { low: \"Low risk\", medium: \"Medium risk\", high: \"High risk\" } as const\n\nfunction InboxSkeleton() {\n  return (\n    <div className=\"grid gap-4 md:grid-cols-[20rem_1fr]\" aria-hidden=\"true\">\n      <div className=\"grid content-start gap-2\">\n        {[0, 1, 2, 3].map((i) => (\n          <div key={i} className=\"grid gap-2 rounded-lg border p-3\">\n            <Skeleton className=\"h-3 w-1/3\" />\n            <Skeleton className=\"h-4 w-4/5\" />\n          </div>\n        ))}\n      </div>\n      <Skeleton className=\"h-72 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * One inbox for every action agents are waiting on: requests sorted newest first with their risk, the full request beside the\n * list, Approve and Deny with A and D, J and K to move, and one button that approves every low-risk request at once.\n */\nfunction Agent2({\n  requests = sample,\n  title = \"Approvals\",\n  onApprove,\n  onDeny,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Agent2Props) {\n  const id = React.useId()\n  const [decided, setDecided] = React.useState<Record<string, ApprovalStatus>>({})\n  const [view, setView] = React.useState<\"pending\" | \"decided\">(\"pending\")\n  const all = requests.map((r) => ({ ...r, status: decided[r.id] ?? r.status }))\n  const pending = all.filter((r) => r.status === \"pending\")\n  const list = view === \"pending\" ? pending : all.filter((r) => r.status !== \"pending\")\n  const [selectedId, setSelectedId] = React.useState<string | undefined>(list[0]?.id)\n  const selected = list.find((r) => r.id === selectedId) ?? list[0]\n  const lowRisk = pending.filter((r) => r.risk === \"low\")\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  function decide(r: Agent2Request, status: \"approved\" | \"denied\") {\n    const index = list.findIndex((x) => x.id === r.id)\n    setDecided((d) => ({ ...d, [r.id]: status }))\n    if (status === \"approved\") onApprove?.(r.id)\n    else onDeny?.(r.id)\n    setAnnounce(`${status === \"approved\" ? \"Approved\" : \"Denied\"}: ${r.title}`)\n    // move on to the next request still waiting, so a queue can be cleared from the keyboard\n    if (view === \"pending\") {\n      const rest = list.filter((x) => x.id !== r.id)\n      setSelectedId(rest[Math.min(index, rest.length - 1)]?.id)\n    }\n  }\n\n  function onListKey(e: React.KeyboardEvent) {\n    if (!list.length || !selected || e.metaKey || e.ctrlKey || e.altKey) return\n    const i = list.findIndex((r) => r.id === selected.id)\n    const key = e.key.toLowerCase()\n    if (key === \"arrowdown\" || key === \"j\") {\n      e.preventDefault()\n      setSelectedId(list[Math.min(i + 1, list.length - 1)]!.id)\n    } else if (key === \"arrowup\" || key === \"k\") {\n      e.preventDefault()\n      setSelectedId(list[Math.max(i - 1, 0)]!.id)\n    } else if (key === \"a\" && selected.status === \"pending\") {\n      e.preventDefault()\n      decide(selected, \"approved\")\n    } else if (key === \"d\" && selected.status === \"pending\") {\n      e.preventDefault()\n      decide(selected, \"denied\")\n    }\n  }\n\n  const ready = state === \"ready\"\n  const empty = ready && list.length === 0\n\n  return (\n    <section data-slot=\"agent-2\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"flex flex-wrap items-center gap-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h2 id={`${id}-title`} className=\"text-2xl font-semibold tracking-[-0.03em]\">\n            {title}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-sm\">\n            {ready ? (pending.length === 1 ? \"1 action is waiting for you.\" : `${pending.length} actions are waiting for you.`) : \"Actions your agents are waiting on.\"}\n          </p>\n        </div>\n        {ready && lowRisk.length > 1 ? (\n          <Button variant=\"outline\" size=\"sm\" onClick={() => lowRisk.forEach((r) => decide(r, \"approved\"))}>\n            <CheckCheck aria-hidden=\"true\" />\n            Approve {lowRisk.length} low-risk\n          </Button>\n        ) : null}\n        <SegmentedControl value={view} onValueChange={(v) => { setView(v as typeof view); setSelectedId(undefined) }} aria-label=\"Show\" size=\"sm\" disabled={!ready}>\n          <SegmentedControlItem value=\"pending\">Waiting{ready ? ` (${pending.length})` : \"\"}</SegmentedControlItem>\n          <SegmentedControlItem value=\"decided\">Decided</SegmentedControlItem>\n        </SegmentedControl>\n      </header>\n\n      <StateView\n        state={empty ? \"empty\" : state}\n        onRetry={onRetry}\n        label=\"Loading approvals\"\n        loading={<InboxSkeleton />}\n        empty={{\n          title: emptyState?.title ?? (view === \"pending\" ? \"All caught up\" : \"Nothing decided yet\"),\n          description: emptyState?.description ?? (view === \"pending\" ? \"When an agent needs your go-ahead, the request appears here.\" : \"Requests you approve or deny are kept here.\"),\n          icon: emptyState?.icon ?? <Inbox />,\n          action: emptyState?.action,\n        }}\n        error={{ title: errorState?.title ?? \"Approvals could not be loaded\", ...errorState }}\n        className=\"mt-6\"\n      >\n        <div className=\"grid gap-4 md:grid-cols-[20rem_1fr]\">\n          <div className=\"grid content-start gap-2\">\n            <ul\n              role=\"listbox\"\n              tabIndex={0}\n              aria-label={view === \"pending\" ? \"Waiting requests\" : \"Decided requests\"}\n              aria-activedescendant={selected ? `${id}-${selected.id}` : undefined}\n              aria-describedby={`${id}-keys`}\n              onKeyDown={onListKey}\n              className=\"grid gap-1.5 rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            >\n              {list.map((r) => {\n                const Icon = riskIcon[r.risk]\n                const isSel = r.id === selected?.id\n                return (\n                  <li\n                    key={r.id}\n                    id={`${id}-${r.id}`}\n                    role=\"option\"\n                    aria-selected={isSel}\n                    onClick={() => setSelectedId(r.id)}\n                    className={cn(\n                      \"bg-card cursor-default rounded-lg border p-3 transition-colors duration-150\",\n                      isSel ? \"border-ring ring-ring/20 ring-2\" : \"hover:bg-accent/50\"\n                    )}\n                  >\n                    <div className=\"text-muted-foreground flex items-center gap-2 text-xs\">\n                      <Bot aria-hidden=\"true\" className=\"size-3.5\" />\n                      <span className=\"min-w-0 flex-1 truncate\">{r.agent}</span>\n                      <span className=\"shrink-0 tabular-nums\">{r.requested}</span>\n                    </div>\n                    <p className=\"mt-1 text-sm font-medium text-pretty\">{r.title}</p>\n                    <p className={cn(\"mt-1.5 flex items-center gap-1.5 text-xs\", riskTone[r.risk])}>\n                      <Icon aria-hidden=\"true\" className=\"size-3.5\" />\n                      <span className=\"text-foreground\">{riskWord[r.risk]}</span>\n                      {r.status !== \"pending\" ? <span className=\"text-muted-foreground\">· {r.status === \"approved\" ? \"Approved\" : r.status === \"denied\" ? \"Denied\" : \"Expired\"}</span> : null}\n                    </p>\n                  </li>\n                )\n              })}\n            </ul>\n            <p id={`${id}-keys`} className=\"text-muted-foreground hidden items-center gap-1.5 px-1 text-xs md:flex\">\n              <Kbd>J</Kbd>\n              <Kbd>K</Kbd> move · <Kbd>A</Kbd> approve · <Kbd>D</Kbd> deny\n            </p>\n          </div>\n\n          {selected ? (\n            <div className=\"grid min-w-0 content-start gap-3\">\n              {selected.task ? (\n                <p className=\"text-muted-foreground text-xs\">\n                  For the task <span className=\"text-foreground font-medium\">{selected.task}</span>\n                </p>\n              ) : null}\n              <ApprovalCard\n                key={selected.id}\n                title={selected.title}\n                description={selected.description}\n                risk={selected.risk}\n                details={selected.details}\n                preview={selected.preview}\n                status={selected.status}\n                onApprove={() => decide(selected, \"approved\")}\n                onDeny={() => decide(selected, \"denied\")}\n              />\n            </div>\n          ) : null}\n        </div>\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Agent2, type Agent2Props, type Agent2Request }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/agent-2/agent-2.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agent",
      "approvals",
      "human in the loop",
      "inbox",
      "queue",
      "review",
      "keyboard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "An approvals queue for many agents. <Agent2 requests={[{ id, agent, title, description, risk: 'low' | 'medium' | 'high', details, preview, requested: '2 min ago', task, status: 'pending' | 'approved' | 'denied' | 'expired' }]} onApprove={(id) => …} onDeny={(id) => …} />. After a decision the next waiting request is selected, so a queue clears from the keyboard.",
      "whenToUse": [
        "A central place where people approve what agents want to do (refunds, emails, deploys, data changes)",
        "Human-in-the-loop review for AI SDK tool calls that need approval or LangGraph interrupts"
      ],
      "whenNotToUse": [
        "One approval inside a single run (use agent-1 or approval-card)",
        "Approving documents or expenses between people"
      ],
      "composesWith": [
        "agent-1",
        "agent-3",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Up / Down or J / K in the list",
          "action": "Moves between requests (a listbox; the selection is announced)"
        },
        {
          "keys": "A / D in the list",
          "action": "Approves or denies the selected request; the decision is announced"
        },
        {
          "keys": "Tab",
          "action": "From the list to the request's Approve and Deny buttons"
        }
      ],
      "customization": [
        "title",
        "Decisions update in place; refresh requests from your backend to confirm",
        "preview takes any node, e.g. a diff-viewer"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "agent-2-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton requests and a detail panel under the real heading; the filter is disabled."
      },
      {
        "id": "empty",
        "title": "All caught up",
        "example": "agent-2-empty",
        "trigger": "state=\"empty\" (or no waiting requests)",
        "note": "Says nothing is waiting. Shown by itself once the last request is decided."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "agent-2-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "agent-2-demo",
      "agent-2-loading",
      "agent-2-empty",
      "agent-2-error"
    ],
    "url": "https://ui.ballmac.com/components/agent-2"
  },
  "categories": [
    "blocks",
    "agents"
  ],
  "type": "registry:block"
}