---
title: Approvals Hub
---

# Approvals Hub

## What it is

**What it is:** a sidebar hub (virtual project `__approvals__`, displayName "Approvals")
that lists the pending **agent-permission approvals** — the CLI permission prompts a
spawned Claude session raises when it asks to run a command, write a file, etc. It gives
those approvals a home in the **Hubs list** on desktop AND mobile, instead of living only
in the Inbox.

### Why it exists

Every other approval kind already has a hub (Cron Jobs, Automation Rules, Recipes), but the
agent-permission (`cli-pending`) approvals were **inbox-only** — there was no hub to open
them from. The Approvals hub closes that gap and mirrors the sibling **Alerts** hub.

## Where to find it

### Visibility

- Gated by the UI-only setting **`approvalsSidebarEnabled`** in `SIDEBAR_RENDER_GATES`
  ([project-visibility.ts](../../src/renderer/src/stores/project-visibility.ts)) — **default ON**.
  Hiding it only removes the sidebar hub; the approvals themselves still ride the unified inbox
  and their approval pane. Mirrors how `alertsSidebarEnabled` gates the Alerts row separately
  from the always-on alerts feature.
- Delivered to mobile via the web bootstrap (`approvalsSidebarEnabled` is in
  `WEB_BOOTSTRAP_SETTING_KEYS`), so hiding/showing it takes effect on the phone too.
- Seeded automatically for existing users on the next launch (the `seedVirtualProjects`
  registry loop is idempotent) — no DB migration.

### Placement

Nested under the **"System"** group (`insights-group`), next to Alerts.

## How it behaves

### How it works

- The hub owns **no data of its own**. It is a **two-pane surface**, like the Alerts, Shares
  and Cron Jobs hubs: the list lives in Pane 2
  ([ApprovalsSidebar](../../src/renderer/src/features/approvals/ApprovalsSidebar.tsx)) and the
  picked approval is read on the right
  ([ApprovalsVirtualProject](../../src/renderer/src/features/approvals/ApprovalsVirtualProject.tsx)),
  so opening an approval no longer covers the queue. Both read the SAME `useCliPendingStore`
  the inbox reads, and the list is the genuine pending approvals through `useApprovalsHubItems`
  ([approvals-hub-items.ts](../../src/renderer/src/stores/approvals-hub-items.ts)).
- **The sidebar owns the loads.** It loads the pending queue and the resolved history, and it is
  the only one of the two panes mounted on mobile (there the sidebar is the `sessions` slot and the
  approval pane is the `session-detail` slot) — so neither load may live on the reading pane, or a
  phone would show an empty queue forever.
- Tapping a row calls `activateUnifiedItem` — the exact path the inbox uses — so
  **approve/deny is the existing** optimistic, chokepoint-guarded, paid-spawn-safe flow. On
  mobile the tap also advances to the `session-detail` slot where the approval pane mounts.
- **Approving or rejecting moves you to the next approval**, the way handling a session moves
  you to the next one that needs you — so the queue can be cleared without returning to the
  list between rows. Handle the last one and the pane just closes.
- **In a popped-out Approvals window** (Open in new window) there is no main-window overlay to
  draw the pane, so the READING PANE draws the approval itself (it asks `isMainAppShell()`);
  closing runs the same `closeApprovalPaneAdvancing`, so it steps to the next approval or back to
  the list. Before this a row there only highlighted. The popped-out list also stays live: the
  sidebar turns on the approvals reload (`useCliPendingPushReload`) outside the main window, where
  the app-wide one never runs, and the Approvals manifest declares `CLI_PENDING_CHANGED` so the
  pop-out receives it. See [pop-out-project-window.md](pop-out-project-window.md).
- **The reading pane's empty state** keys on the PENDING queue, not on the history: it prompts
  "Select an approval" only while there is something to select, and says "No pending approvals"
  whenever the queue is empty — even when the history has rows, because a resolved approval is a
  read-only look-back and prompting to open one would point at nothing.
- The `cli-pending` integration still **owns the inbox source** (`cli-pending-approval`); the
  hub is a presentation surface layered on top. Cron-failure NOTICES that share the
  cli-pending table are excluded (they bucket to their originating project / the Cron Jobs
  failures section, not to Approvals).

### Two sections: "Needs you now" + "Past approvals"

The sidebar stacks two sections (mirroring the Alerts hub's active + "Recently dismissed"):

- **Needs you now** — the pending approvals still waiting on the user (the actionable rows
  described in *How it works*). When it's empty but history exists, a short "you're all
  caught up" line shows instead.
- **Past approvals** — a collapsible, **read-only** history of already-handled approvals
  (approved *or* rejected), below the pending list, **collapsed by default**. Each row shows
  the request, an **Approved/Rejected** outcome label (semantic status colour + `CheckCircle2`/
  `XCircle` icon), and when it was resolved. Rows are **non-interactive** — a resolved approval
  has no action, and (unlike the pending rows) it is never routed into the approval pane, which
  resolves only pending rows and would otherwise hang (the trap the Alerts dismissed-rows hit).

**Where the past data comes from — no backend change.** `loadResolved` on the `cli-pending`
store fetches the resolved rows (`status: 'approved'` + `status: 'rejected'`) through the SAME
`CLI_PENDING_LIST` IPC the pending list uses — resolved rows are already served (the display-TTL
window applies only to `pending`; resolved rows persist until the retention pruner). They land
in a SEPARATE `resolved` store slice, so the inbox source filter and the badge (which read
`pending` only) are **untouched**. `cliResolvedSelectItems`
([cli-pending-approval-items.ts](../../src/renderer/src/stores/cli-pending-approval-items.ts))
maps them to read-only display rows through the SAME `rowHeadline` the pending list uses — which
is why a legacy row queued before the local-time fix (2026-09-11) reads correctly HERE too: its
stored preview carries a raw, cap-truncated `until 2026-08-06T13:00`, and the shared headline
seam rebuilds the wake stamp from the row's intact `payloadJson` rather than from that text (the
text cannot be repaired — a truncated fragment has lost its zone, and a zone-less value parses as
LOCAL, so reformatting it would show the WRONG time). It excludes cron-failure notices via the
SAME `isGenuineApprovalRow` predicate the pending mapping uses (so the two lists can never drift),
reuses the pending list's title formatting, sorts newest-resolved first, and caps at
`RESOLVED_APPROVALS_DISPLAY_CAP` (50). The section reloads on mount and whenever the pending
count changes, so a just-handled approval drops into the history without a remount.

### Badge count

The hub badges the **total pending agent-permission approvals** (a global roll-up, computed
by `countGenuinePendingApprovals` in
[useProjectsSidebarCounts.ts](../../src/renderer/src/features/dashboard/useProjectsSidebarCounts.ts)).
To avoid double-counting, `cli-pending` no longer contributes to the **Cron Jobs** hub's
badge. An approval attached to a real project badges **that project through the same rule that
renders its row** — both read the one registry and the one list, so the count is always what the
hub shows when you open it. It still badges this roll-up as well: an approval legitimately shows
in both places.

### The fifth surface: the project hub the approval is attached to

Since 2026-09-25 an approval also appears in the **Needs-You section of the project hub it is
attached to** — so a decision waiting on you is visible in the project you are actually working
in, instead of only in this hub and the Inbox.

- **Which hub.** The project the approval is *attached to*, resolved by one rule
  ([approval-owning-project.ts](../../src/renderer/src/features/cli-pending/friendly-payload/approval-owning-project.ts)):
  the project it **targets**; else the project of the **session it targets**; else the project
  named in its payload; else the project of the **session that asked**. A kind that acts on
  nothing of its own — a settings change, a recipe run — therefore lands in the asking session's
  project.
- **Never guessed.** Anything that does not resolve to a real, live project (a virtual hub, a
  soft-deleted project, an archived asking session, an unparseable payload) keeps the generic
  bucket and simply shows on its existing surfaces. It is never attached to a wrong hub.
- **Same row, not a copy.** The project hub renders it through the same attention-row path as
  every other notice, so approving from there runs the same chokepoint as here — one row,
  answerable from any surface, cleared from all the others.
- **It stays HERE too.** Attaching an approval to a project never removes it from this hub:
  every approval appears in both places.
- **Worth knowing:** middle-clicking a project hub row clears that hub's backlog, and for an
  approval that clear **rejects** it. That row class is already flagged irreversible, so the
  confirmation names it, lists it apart and opens with Cancel focused.

### The fourth surface: the asking session's own conversation

Since 2026-09-08 an approval also renders as a card **above the composer of the session that
raised it** — so the ask sits next to the message explaining it, and the user never has to leave
the thread to answer. Same row, same chokepoint, same audit trail as the inbox and this hub;
answering on any surface clears it from the others.

- **Scope:** every kind the session raised, matched on `sourceSessionId` + `status === 'pending'`.
  It began as the git-guardrails break-glass only and the owner widened it, so an old note saying
  "guardrails only" is stale.
- **Several asks page, they do not pile up (2026-09-09).** Two or more open approvals render as a
  swipeable **deck** — one card showing at a time, with an "Approval 2 of 4" counter and prev/next
  arrows above it, and a sideways swipe on touch. So the surface is one card tall no matter how
  many are waiting, and the conversation underneath stays readable. A single ask renders bare,
  with no pager. Before this they stacked, and three of them buried the message box.
- **Bounded:** at most 10 cards in the deck, then a count + a link here. Since the deck no longer
  grows with the count, that cap is only about how many cards are worth putting on the page.
- **One card is never taller than a few lines (2026-09-26).** The ask shows at most four lines and
  the agent's reason at most two, so Approve and Decline always stay on screen, even on a phone.
  Before this, a card whose ask listed twenty file paths grew taller than the phone and pushed its
  own buttons out of reach. The whole text is still in the card's tooltip and in **Details**. When
  several cards are up at once (say an approval plus a permission ask), they share one area that
  scrolls inside half the screen, so every button can always be reached.
- **Same safety gate:** a destructive or money-spending kind clears the SAME confirm the inbox
  pane shows — one shared `confirmDangerousApproval`, so the two can never drift.
- **"Details"** on a card opens the full pane here/in the inbox (where "always allow" and the raw
  payload live) and returns the user to the conversation once they answer.

Full invariants: [in-conversation-approvals-contract.md](../../.claude/memory/contracts/in-conversation-approvals-contract.md).

## For agents

### Advancing to the next approval — the trap

The advance is the ordinary one: `resolveInboxAction` pre-advances the cursor with
`getNavigableItems(view, 'attention')` + `findNextItem`. What is NOT ordinary is how the hub
gets a nav list at all.

The hub is a **virtual project**, so no session carries its UUID, and its rows are unscoped
agent-permission approvals whose `projectId` is **the real project the approval is attached to,
or the CRON sentinel** when nothing on the row resolves to one (`deriveItemProjectId`) — never
the hub's UUID, which is the only thing `selectProjectAttentionItems` matches on. The
generic project path therefore returns an **empty** list for this hub, and an empty nav list
silently disables the advance: the dispatcher captures no nav context and skips it. That was
the 2026-08-30 bug — approve or reject from the hub and you stayed parked on the row you had
just handled.

`getNavigableItems` now short-circuits on `isApprovalsHubProject(projectId)` and returns the
hub's OWN queue from
[approvals-hub-items.ts](../../src/renderer/src/stores/approvals-hub-items.ts) — the same
selector the sidebar renders, so nav order IS rendered order. That one seam also covers the
X-dismiss advance and keyboard nav in the hub. **Do not** re-derive the queue anywhere else:
`getApprovalsHubItems` is locked to the resolver by
[nav-group-single-source.test.ts](../../tests/unit/lint/nav-group-single-source.test.ts).

### The third trap — a row's group is not its project

A row's **section** and its **project** are two different things, and for an approval they now
differ: every genuine approval is pinned to ONE Approvals group (so the queue stays contiguous),
while its `projectId` names the real project it acts on. They used to be the *same* value for an
approval, which made reading `projectId` as a group key safe **by coincidence** — and attaching
approvals to their real projects ended that coincidence. `findNextItem` then cannot find the
row's group, and the advance lands on an unrelated row: the "rejected an approval and it took me
to the next project" bug.

Every place that groups, walks or advances between rows must therefore key on
**`inboxItemGroupKey(item)`** ([inbox-group-key.ts](../../src/renderer/src/stores/inbox-group-key.ts)),
which is `item.inboxGroupId ?? item.projectId` — the same expression the inbox grouping uses.
Hand-writing that expression anywhere is the mistake this helper exists to end.

### The second trap — the pane you just approved from is still alive

The desktop overlay keys each approval pane on the approval id inside `AnimatePresence`
([compute-overlay-key.ts](../../src/renderer/src/features/dashboard/compute-overlay-key.ts)), so
when a resolve pre-advances from approval A to approval B, A's pane stays **mounted through its
exit animation** — still subscribed to its store. Its "my row is no longer pending → `onClose()`"
auto-close fires the moment the optimistic approve lands. Until 2026-09-22 that close was
context-free (`closeApprovalPaneAdvancing()` closed whatever approval was open), so it cleared B
the instant the dispatcher selected it, and the passive visibility-recovery — which skips
approvals by design — filled the slot with an alert: the 2026-09-21 "approving never selects the
next approval" report.

The close is now **self-scoped** (`approval-close-is-self-scoped` in the inbox-navigation
contract): `closeApprovalPaneAdvancing(closing)` requires the identity of the approval being
closed and ignores one that is no longer open, and
[ActiveApprovalPane.tsx](../../src/renderer/src/features/dashboard/ActiveApprovalPane.tsx) binds
that identity once for every pane. Locked by
[approval-pane-stale-close.test.tsx](../../tests/unit/features/dashboard/approval-pane-stale-close.test.tsx)
(the real dispatcher, cron store and subscriber against a pane left on the approved row) and
[approval-close-advance.test.ts](../../tests/unit/stores/approval-close-advance.test.ts). **Do
not** route a pane close to the chokepoint without the pane's own approval, and do not "fix" a
stale pane by removing its auto-close — the push-resolved-elsewhere case needs it.

## Related

Sibling pages: [cli-pending-actions.md](cli-pending-actions.md) covers the CLI permission prompts
this hub lists, [inbox-alerts.md](inbox-alerts.md) covers the unified inbox the same approvals ride,
and [agent-permission-level.md](agent-permission-level.md) covers the permission levels that decide
when a prompt is raised at all.
