feat(web): add global approval banner for blocked tasks (#1167) #1197
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1197
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "code-lead/1167"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #1167.
The inline
<ApprovalCard>only shows inside the open task's event log, so a gate on a different task can sit invisible for the full 5-minute window before auto-deny fires. This PR adds a sticky banner that surfaces every pending approval gate above main content on every operational route, fed live by SSE.Changes
useApprovalsStore(new, zustand) — pending-gate registry keyed bycallId, fed by SSEapproval_pending/approval_decisionrows plus terminal task events (result,cancelled) so the banner clears the instant the operator decides.<GlobalApprovalBanner>(new) — mounts insideAppShellwith overlay positioning (no content push),role="alert", warning amber colours (--ch-color-warning, not error red — approval is not failure), and a prev/next cycler that lets the operator step through every open gate without leaving the current route. The "Open task" link uses<Link to="/agents/live" search={{ detail: taskId }}>for a uniform deep-link./settings/*,/login,/onboarding; visible everywhere elseAppShellrenders (/board,/workspace,/agents/live,/agents/roster, …).parseApprovalPending,parseApprovalDecisionCallId,isTerminalTaskEventare exported from the store module so unit tests can exercise the SSE → store shape without mounting a component or opening anEventSource.Acceptance criteria
<GlobalApprovalBanner>rendered in the AppShell when ≥ 1 task has a pendingapproval_pendingevent_one/_otherkeys viaIntl.PluralRules)currentIndexstate)approval_decision→clearByCallId; terminal task event →clearByTaskId)bg-warning/10+border-warning), not error redabsolute inset-x-0 top-0inside<main>so the page never gets pushedrole="alert"on the banner root/board,/workspace,/agents/live,/agents/roster(verified per-route in tests)/settings/*)just qapasses locally (typecheck, lint, biome format, unit tests, paraglide-check, i18n-string-check)Test plan
bun x vitest run src/stores/approvals-store.test.ts— 15 pure-logic tests cover record/clear/parse pathsbun x vitest run src/components/global-approval-banner.test.tsx— 18 component tests cover visibility gating, count rendering, cycler, and link targetbun x vitest run src/components/app-shell.test.tsx— existing AppShell suite stays green (banner mock added)just qaruns end-to-end (typecheck + lint + format + all unit tests + sql-layer + paraglide + i18n + flow-schema)approval_pendingSSE event in dev mode (will validate post-merge against the dev fleet)🤖 Generated with Claude Code
Code Review —
feat(web): add global approval banner for blocked tasksCI ✅ green. No prior reviews. APPROVED — merging below.
Overview
Fixes a real operator UX gap: the existing
<ApprovalCard>is only visible inside the event log of the currently-open task, so a blocked gate on any other task sits invisible for up to 5 minutes before auto-deny fires. This PR adds a sticky global banner that surfaces every pending gate on every operational route, with a prev/next cycler so the operator can step through multiple open gates without leaving the current page.Architecture is clean: Zustand store → SSE bridge hook → pure presentational component, with parsers exported separately for unit-testability. Exactly the right layering for this kind of ambient UI concern.
What's good
callId(nottaskId) correctly handles the "multiple gates per task" edge case while still lettingclearByTaskIdsweep them all on terminal events. The idempotentrecordPending+reset()on SSE reconnect is a solid double-defence against replay double-counting.role="alert",aria-labelon the container,aria-live="polite"on the cycler indicator,aria-hiddenon decorative icons — complete coverage.app-shell.test.tsxmock so existing tests keep passing. Missing a direct test foruseApprovalsSSEitself, but it's a thin glue layer and the integration is exercised via the component tests.Intl.PluralRulesinpluralLabelis forward-compatible with additional locales.currentIndexclamp: theuseEffect+Math.minguard in render correctly handles the intermediate render frame wherepending.lengthhas shrunk but state hasn't updated yet. Subtle edge case, handled correctly.Nits (non-blocking)
1. Stale doc comment: says
sticky, JSX usesabsolute(global-approval-banner.tsx, Layout section of the file-level JSDoc):The actual class string is
absolute inset-x-0 top-0 z-40. Theabsoluteapproach (relative to the<main>which gainedposition: relative) is the right call for the "no content push" AC — the comment is just stale from an earlier draft.2. Duplicated sort between selector and component —
selectPendingApprovals(store) and the component'suseMemoboth do[...pending.values()].sort((a, b) => a.openedAt - b.openedAt). The component could use the selector directly (useApprovalsStore(selectPendingApprovals)) and drop the intermediatependingMapsubscription. Current approach is correct; just noting the duplication.3.
clearByTaskIddeletes from Map while iterating — safe per spec (Map iterators skip entries removed after creation), but non-obvious. A filter-based alternative reads more clearly:Not a bug — readability note only.
4.
deadlineAtnot surfaced in banner UI — the field is stored but not shown. A countdown for the 5-minute window would reinforce urgency. Fine as a follow-up; not in this ticket's AC.Verdict
No blockers. All three nits are polish items that do not affect correctness or the acceptance criteria. Merging.
Code Review —
feat(web): add global approval banner for blocked tasksCI ✅ green. No prior reviews. APPROVED — merging below.
Overview
Fixes a real operator UX gap: the existing
<ApprovalCard>is only visible inside the event log of the currently-open task, so a blocked gate on any other task sits invisible for up to 5 minutes before auto-deny fires. This PR adds a sticky global banner that surfaces every pending gate on every operational route, with a prev/next cycler so the operator can step through multiple open gates without leaving the current page.Architecture is clean: Zustand store → SSE bridge hook → pure presentational component, with parsers exported separately for unit-testability. Exactly the right layering for this kind of ambient UI concern.
What's good
callId(nottaskId) correctly handles the "multiple gates per task" edge case while still lettingclearByTaskIdsweep them all on terminal events. The idempotentrecordPending+reset()on SSE reconnect is a solid double-defence against replay double-counting.role="alert",aria-labelon the container,aria-live="polite"on the cycler indicator,aria-hiddenon decorative icons — complete coverage.app-shell.test.tsxmock so existing tests keep passing. No direct test foruseApprovalsSSEitself, but it's a thin glue layer and covered indirectly by the component tests.Intl.PluralRulesinpluralLabelis forward-compatible with additional locales.currentIndexclamp: theuseEffect+Math.minguard in render correctly handles the intermediate render frame wherepending.lengthhas shrunk but state hasn't updated yet — subtle edge case, handled correctly.Nits (non-blocking)
1. Stale doc comment: says
sticky, JSX usesabsolute(global-approval-banner.tsx, Layout section of the file-level JSDoc):The actual class string is
absolute inset-x-0 top-0 z-40. Theabsoluteapproach (relative to the<main>which gainedposition: relative) is the right call for the "no content push" AC — the comment is just stale from an earlier draft.2. Duplicated sort between selector and component —
selectPendingApprovalsin the store and the component'suseMemoboth do[...pending.values()].sort((a, b) => a.openedAt - b.openedAt). The component could use the selector directly (useApprovalsStore(selectPendingApprovals)) and drop the intermediatependingMapsubscription. Current approach is correct; just noting the duplication.3.
clearByTaskIddeletes from Map while iterating — safe per spec (Map iterators skip entries removed after creation), but non-obvious. A filter-based form reads more clearly:Not a bug — readability note only.
4.
deadlineAtnot surfaced in banner UI — the field is stored but not displayed. A countdown for the 5-minute window would reinforce urgency for the operator. Fine as a follow-up; not in this ticket's AC.Verdict
No blockers. All four nits are polish items that do not affect correctness or the acceptance criteria. Merging.