# Approval cards

> The approval card, the file-edit review and the safelist from zidane-ui, on the desktop app's IPC, on zidane-server or in memory.

`zidane-ui/approvals` shows the tool calls that wait for your answer when [Tool approvals](https://zidane.si/docs/approvals.md) is not Full access. The desktop app renders these components (its keys are on [desktop approvals](https://zidane.si/docs/desktop-approvals.md)). Your app renders them on the same host interface.

The preview runs on demo data. Request three approvals: a command, a one-hunk edit, then a three-hunk edit from a subagent. Answer each one to see the next.

## What it shows

| Component | Shows |
| - | - |
| `ApprovalBar` | The first pending approval of a session, above its composer. A tool that writes files (`edit`, `multi_edit`, `write_file`) gets the file-edit review. Every other tool gets the inline card. Renders nothing when the session has no pending approval |
| `ApprovalCard` | The inline card: the tool, what worried the decision model, the command (or the input as JSON), and Deny, Always allow, Allow once |
| `FileEditApproval` | The file-edit review: the path, the subagent that asked, the diff, and Deny, Allow for session, Always allow, Allow. With several hunks, one checkbox per hunk. Only the hunks found in the file start checked, and Allow answers the hunks you keep |
| `SafelistPanel` | The "Always allow" entries of each project, with Revoke |
| `ApprovalShell` | The panel the review and the safelist draw in when your app gives them no shell of its own |

| Keys | Inline card | File-edit review |
| - | - | - |
| Allow once | `cmd+enter` | `A` |
| Allow for session | | `S` |
| Always allow | `cmd+shift+enter` | `P` |
| Deny | `Esc` (not from inside the code editor) | `D`, `Esc`, a click on the modal scrim |

The card takes focus when it shows, so a stray `Enter` meant for the composer never allows a command. If you are typing in a field or the code editor, the focus stays there. The review starts on Allow.

## In your app

Your app passes an `ApprovalsHost` to `ApprovalsProvider`, or as `approvals` to `AppHostProvider`. The host holds the pending approvals as a readable store, and answers them. If you leave out a member, its control does not show.

| Member | Does | Without it |
| - | - | - |
| `pending` | The approvals waiting for an answer, oldest first. The host keeps it current: new requests, requests answered elsewhere, a new read after a reconnect | Required |
| `resolve(requestId, decision)` | Answers one approval. It leaves `pending` at once | The card shows the request without its buttons |
| `denyAll(sessionId?)` | Denies every pending approval, or those of one session | No bulk deny |
| `safelist` | The "Always allow" entries: `state`, `refresh()`, `remove(project, entry)` | `SafelistPanel` renders nothing |

```tsx
import { ApprovalBar, ApprovalsProvider, SafelistPanel } from 'zidane-ui/approvals'
import { createServerApprovalsHost, createServerClient, createServerHost } from 'zidane-ui/adapters'

const client = createServerClient({ url: 'wss://agent.example.com', token: 'zds_…' })
await client.ready // the approvals host reads from the handshake whether the token may answer
const chat = createServerHost({ client })
const approvals = createServerApprovalsHost({ client })

export function Composer({ sessionId }: { sessionId: string }) {
  return (
    <ApprovalsProvider host={approvals}>
      <ApprovalBar sessionId={sessionId} />
    </ApprovalsProvider>
  )
}
```

To open the file-edit review on your own modal layer, pass `onFileEdit` to `ApprovalBar`. It is called once per request. Then render `FileEditApproval` there with `onDone` to close the modal, and `renderShell` to draw it in your modal's panel. `ApprovalCard` takes `renderFrame` for your own card surface. The desktop app does both.

| Host | From | Offers |
| - | - | - |
| zidane-server | `createServerApprovalsHost({ client })` (`zidane-ui/adapters`) | `pending`, `resolve`, `denyAll`. Answering needs a `chat` or `admin` [scope](https://zidane.si/docs/server-security.md#scopes). Built after the handshake (`await client.ready`), the host follows `client.can('approvals.resolve')`: a connection that may not call it shows approvals without buttons. `canApprove` overrides it. `safelist` over `safelist.list`, its `remove` over `safelist.remove` (a `chat` token) |
| Desktop app | Its IPC host (`gui/src/renderer/src/hosts/electron-approvals-host.ts`) | Every member |
| In memory | `createFixtureApprovalsHost` (`zidane-ui/approvals/testing`) | Every member, for demos and tests |

Your own host can build `pending` with `createPendingApprovals`, which keeps a queue the way every host above does, and `safelist` with `createSafelist`. Its tests run the shared suite: `describeApprovalsHostContract` from `zidane-ui/approvals/testing/contract` (vitest).

```ts
import { describeApprovalsHostContract } from 'zidane-ui/approvals/testing/contract'

describeApprovalsHostContract('my host', backend => ({ host: makeHost(backend), raise, resolveElsewhere }), ['resolve', 'denyAll'])
```

Every string comes from the labels (`DEFAULT_APPROVALS_LABELS`). Override them on `ApprovalsProvider` or `LabelsProvider`.

| Entry point | Holds |
| - | - |
| [`zidane-ui/approvals`](https://zidane.si/docs/reference/ui-approvals.md) | `ApprovalsProvider` and its hooks, `ApprovalBar`, `ApprovalCard`, `FileEditApproval`, `SafelistPanel`, `ApprovalShell` |
| [`zidane-ui/approvals/headless`](https://zidane.si/docs/reference/ui-approvals-headless.md) | `ApprovalsHost`, `createPendingApprovals`, `createApprovalHeads`, `createSafelist`, the preview helpers and the labels |
| [`zidane-ui/approvals/testing`](https://zidane.si/docs/reference/ui-approvals-testing.md) | `createFixtureApprovalsHost` and the demo approvals |
| `zidane-ui/approvals/testing/contract` | `describeApprovalsHostContract`, the shared host suite |
