# Approval Inbox

> An animated approval inbox for React and shadcn, for deciding an agent's tool calls from the keyboard.

An operator's queue of tool calls waiting on a person: a pill counts what is waiting, the request in focus opens into an Approval Card with its diff, and J, K, A and D decide from the keyboard. Each decision folds its card to the outcome, then the row collapses while the count rolls down; when the queue is clear the pill shrinks to a check.

- Tier: Eased Pro
- Interaction: Press
- Page: https://eased.dev/components/approval-inbox
- Installs: motion, diff, @base-ui/react

## Install

Approval Inbox ships with Eased Pro (https://eased.dev/#pricing). Members add the `@eased-pro` registry with their token (https://eased.dev/account), then run:

```bash
npx shadcn add @eased-pro/approval-inbox
```

The screen an operator clears agent requests from: a queue of tool calls waiting on a person, the one in focus open as an Approval Card with its diff, and a Decided list beside it. Built on Approval Card, Badge, Number Roll, Text Swap, Kbd and Textarea.

## When to use

- The approvals page of an agent product: agents ask to send email, run a migration or spend money, and a person decides each one from a queue.
- Operators who triage many requests in a row and want to stay on the keyboard: J and K to move, A held to approve, D to deny, E to edit.
- Requests that change something you can show as text: a file, a query, a config, an email draft.

## When not to use

- One request inside a chat reply: use [Approval Card](/components/approval-card) on its own, inline with the message.
- A yes/no question with nothing to show: use a confirm dialog.

## Usage

```tsx example.tsx
import { ApprovalInbox, type ApprovalRequest } from "@/components/eased/approval-inbox/approval-inbox";

export function ApprovalsPage({ requests, decide, edit }: { requests: ApprovalRequest[]; decide: Decide; edit: Edit }) {
  return (
    <ApprovalInbox
      className="h-dvh"
      requests={requests}
      onApprove={(r) => decide(r.id, "approved")} // set state "running", then "done"
      onDeny={(r) => decide(r.id, "denied")} // set state "denied"
      onEdit={(r, files) => edit(r.id, files)} // store the new files and set edited
    />
  );
}

const request: ApprovalRequest = {
  id: "apr_9m4t",
  title: "Raise the GPU spend cap to $2,600 a month",
  tool: "update_budget",
  agent: "Infra agent",
  kind: "payment",
  reason: "Fine-tuning jobs hit the $2,000 cap on 14 October and were paused twice.",
  files: [{ path: "infra/budgets.yaml", before, after }],
  requestedAt,
};
```

The inbox never decides on its own: it calls `onApprove`, `onDeny` and `onEdit`, and you set each request's `state` (and `decidedAt`) as your backend confirms. Keep decided requests in the array; they show their outcome in the queue for a moment, fold away and move to the Decided list.

## API reference

### ApprovalInbox

| Prop | Type | Default | Description |
|---|---|---|---|
| `requests` | `ApprovalRequest[]` | | Every request, waiting and decided, oldest first. Append new ones as agents ask. |
| `onApprove` | `(request) => void` | | The hold on Approve (or A) completed. |
| `onDeny` | `(request) => void` | | Deny (or D). |
| `onEdit` | `(request, files) => void` | | Save in the editor. Leave it out to hide Edit. |
| `emptyAction` | `ReactNode` | | Under the empty state's check. |
| `now` | `number` | time of mount | Epoch ms for relative times ("6 min ago"). |
| `holdMs` | `number` | `800` | How long Approve must be held. |
| `locale` | `string` | `"en-US"` | |
| `className` | `string` | `""` | Appended to the root. Give it a height (`h-dvh`, `flex-1`); the queue and the Decided list scroll inside it. |

### ApprovalRequest

| Field | Type | Description |
|---|---|---|
| `id` / `title` | `string` | The request, and what it will do as a short imperative. |
| `tool` / `agent` | `string` | The tool called (`send_email`) and the agent asking. |
| `reason` | `ReactNode` | Why, in a sentence, beside the tool's name. |
| `files` | `ApprovalFile[]` | `{ path, before, after }` or `{ path, hunks }`, as Approval Card takes them. Only files with `after` text are editable. |
| `kind` | `"email" \| "database" \| "payment" \| "message" \| "code" \| "tool"` | The leading glyph. Default `"tool"`. |
| `requestedAt` | `number` | Epoch ms. |
| `state` | `ApprovalState` | `awaiting-approval` (default), `running`, `done`, `denied`, `expired` or `error`. |
| `decidedAt` | `number` | Epoch ms, for the Decided list. |
| `edited` | `boolean` | Shown as "Edited" in the queue and "Approved with edits" once decided. |

## Layout

Two columns from 64rem: the queue and the Decided list (18rem). Below 64rem the Decided list folds away and the queue fills the screen. The shortcut legend shows from 48rem on devices with a fine pointer. Below 30rem each row drops its agent and time and keeps the title and change count. Nothing scrolls sideways at 390px; long diff lines scroll inside the diff.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves to the open request, then its Deny, Edit and Approve. The queue is one tab stop. |
| `J` / `K`, `↓` / `↑`, `Home` / `End` | Move between waiting requests and open them. |
| `A` (held) | Approves the open request when the hold completes; let go early to cancel. |
| `D` | Denies the open request. |
| `E` | Edits the open request's change. In the editor, `Escape` cancels and `⌘ Enter` (`Ctrl Enter`) saves. |

## Accessibility

- The queue is a list; the open request is an Approval Card (a `group` named by its title) that takes focus as you move, so its title is read on arrival. Folded requests are buttons named "Open: …" for pointer and screen reader users.
- Focus follows the decision: when a request is decided, the next one opens and takes focus; when the last one goes, focus moves to the "All caught up" heading. Nothing takes focus on mount.
- Each card announces its own outcome ("Approved: …"); the inbox's status region then announces the count ("3 waiting", "All caught up").
- The shortcut legend is plain text with keycaps that press as the keys do, so it is read as written.

## Motion

On mount the header, queue and Decided list rise 4px in turn (`stagger.line`, once). From then on everything is state-driven. Moving opens the next card on Auto Height (smooth, no overshoot) while the last one folds to its header. A decision folds the card to its outcome (the check draws, the title gains "Approved:" through Text Swap); 0.9s later the row collapses on the smooth spring while the pill's count rolls down, and the request rises into the Decided list as the rest glide down. A request arriving unfolds at the bottom as the count rolls up. When the queue is clear the pill's label gives way and it shrinks to a check, and a larger check draws in where the queue was (bouncy, from 0.96). Reduced motion: fades only, nothing rolls, slides or springs.

## Notes for AI

Pick this for an approvals page where a person clears many agent requests; pass the real before/after text so each card's diff is the thing approved, and drive `state` from your backend (`running` while an approved call applies, then `done`). Keep request ids stable and keep decided requests in the array so the queue can fold them away.

Do not add confirm dialogs (the hold is the confirmation), a second count beside the pill, or your own keyboard handlers for J, K, A, D and E. Do not wrap it in a card: it is the page.

## Related

- [Approval Card](/components/approval-card): one request on its own, the card each row opens into.
- [Agent Run Console](/components/agent-run-console): watching the runs these requests come from.
