Skip to content

Commit d1fc789

Browse files
authored
feat(web-ui): async notifications — browser + in-app center (#559)
## Summary - Browser notifications via Web Notifications API (batch.completed, blocker.created, gate.run.failed) — fire only when tab is hidden and permission is granted - In-app notification center: bell icon + dropdown in sidebar footer, unread badge, last 20 notifications, mark-as-read / mark-all-read / clear-all, persisted in workspace-scoped localStorage - Permission requested once on first visit to /execution when permission is 'default' - FAILED/CANCELLED batches distinguished from COMPLETED (status-specific message + icon) - Cross-tab storage events filtered by key - localStorage writes are crash-safe (quota/private-mode protection) - a11y: aria-expanded, aria-controls, role="dialog", Escape-to-close ## Validation - Tests: 874/874 passing (locally + CI) - Lint: Clean (no new findings) - Build: Green - Test mutation check: 4 mutations applied, all caught (Phase 7d) - Internal review: Completed (advisory) - Cross-family review: **codex** — 3 findings: 2 fixed, 1 documented as Known Limitation (cross-page background poller out of scope) - Early-PR feedback across 4 rounds: - CodeRabbit: 3 findings → 2 fixed (a11y, persist try/catch), 1 skipped with justification (pre-prompt banner deviates from approved plan) - claude-review: 4 findings → 4 fixed (a11y, persist guard, gate re-notification ref, storage key filter, Escape key), 1 skipped with technical rebuttal (SWR claim incorrect — useProofRun does not use SWR) - Final triage cutoff 2026-05-21T18:58:00Z — claude-review's final comment confirms "This PR is ready to merge" - Demo: All 5 acceptance criteria verified with outcome evidence (screenshots in /tmp/demo-559/) ## Known limitations (documented in PR body) - Notifications only fire while BatchExecutionMonitor is mounted. Global background poller for cross-page notifications is out of scope. - Webhook integration (#560) is the only Phase 5.3 piece deferred. Closes #559
1 parent c7ea606 commit d1fc789

18 files changed

Lines changed: 1123 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol
3636

3737
### Current Focus: Phase 4A
3838

39+
**Phase 5.3 (browser + in-app center) is complete** — Async notifications: `useNotifications` hook with workspace-scoped `localStorage` persistence and browser Notification dispatch (only when tab hidden + permission granted); `NotificationProvider` in root layout; `NotificationCenter` (bell icon + dropdown) mounts in sidebar footer. `BatchExecutionMonitor` dispatches `batch.completed` on terminal status transitions (distinguishing COMPLETED/FAILED/CANCELLED in both the in-app message and the success icon) and `blocker.created` on per-task BLOCKED transitions. `/execution` requests browser permission once on mount when permission is `'default'`. `/proof` dispatches `gate.run.failed` per failed gate when a proof run completes with `passed === false`. Known limitation: notifications only fire while `BatchExecutionMonitor` is mounted (cross-page background poller is out of scope; tracked for future work). Webhook (#560) deferred. Issue #559.
40+
3941
**Phase 5.2 is complete** — Costs page now ships per-task and per-agent breakdowns (#558) on top of the spend summary (#557). Backend: `GET /api/v2/costs/tasks?days=N&limit=M` (top-N tasks with titles, agent, tokens, cost) and `GET /api/v2/costs/by-agent?days=N` (per-agent rollup + total input/output tokens), both via `TokenRepository.get_top_tasks_by_cost` and `get_costs_by_agent`. Task board cards show an inline `MoneyBag02Icon` cost badge with token-breakdown tooltip when cost data exists. Fixed a v2 data-loss bug where `react_agent` int-cast UUID task IDs and stored NULL in `token_usage`.
4042

4143
**Phase 5.1 is complete** — Settings page now ships three working tabs: Agent (#554), API Keys (#555), and PROOF9 Defaults + Workspace Config (#556). Backend: `GET/PUT /api/v2/proof/config` and `/api/v2/workspaces/config`, plus `run_proof()` now honors `enabled_gates` filtering and `strictness` (`strict` vs `warn`). Atomic JSON writes via `codeframe/ui/routers/_helpers.atomic_write_json`. The 9-gate canonical order and `proof_config.json` filename live in `codeframe/core/proof/models.py`.

docs/PRODUCT_ROADMAP.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,19 @@ Without a settings page, a new user who cannot find the env vars cannot use the
126126

127127
### 3. Async Notifications
128128

129-
**Current state**: Batch executions can run for hours. The user has no notification when a batch completes, a blocker is created, or a gate run fails.
129+
**Current state**: Browser notifications + in-app notification center shipped (#559). Webhook integration (#560) is the only remaining piece — out of scope for #559, tracked separately.
130130

131-
**What to build**:
131+
**What was built (#559)**:
132132

133-
- **Browser notifications** (Web Notifications API): opt-in, triggered on batch completion, blocker creation, and gate run failure — follow the existing WebSocket event stream for triggers
134-
- **In-app notification center**: a bell icon in the sidebar with a history of recent notifications, clearable
135-
- **Optional webhook**: a single URL the user can configure to receive JSON payloads on key events (batch done, blocker created, PR merged) — supports Slack, Discord, or any HTTP endpoint
133+
- **Browser notifications** (Web Notifications API): fire only when the tab is hidden and permission is granted, for batch terminal transitions (COMPLETED/FAILED/CANCELLED labeled distinctly), per-task BLOCKED transitions, and PROOF9 gate failures
134+
- **In-app notification center**: bell icon in the sidebar footer with unread badge and dropdown panel; last 20 notifications, per-item dismiss, mark-all-read, and clear-all actions; persisted in `localStorage` scoped per workspace
135+
- **Permission request**: fires once on first visit to `/execution` only when permission state is `default`
136+
137+
**Known limitation**: notifications only fire while the `BatchExecutionMonitor` is mounted — a global background poller for cross-page notifications would require an architecture change and is out of scope for #559.
136138

137-
The webhook is optional and last priority. Browser notifications and the in-app center are sufficient for the core use case.
139+
**What's still planned (#560)**:
140+
141+
- **Optional webhook**: a single URL the user can configure to receive JSON payloads on key events (batch done, blocker created, PR merged) — supports Slack, Discord, or any HTTP endpoint
138142

139143
---
140144

@@ -196,7 +200,7 @@ These are items that were considered and excluded because they do not serve the
196200
| 4B | Post-merge glitch capture loop | ❌ Not started ||
197201
| 5.1 | Settings page (skeleton + agent config + PROOF9/workspace tabs) | ✅ Complete | #554–556 |
198202
| 5.2 | Cost analytics | ✅ Complete | #557–558 |
199-
| 5.3 | Async notifications | ❌ Not started | #559–560 |
203+
| 5.3 | Async notifications | 🚧 Browser + in-app center shipped (#559); webhook (#560) deferred | #559–560 |
200204
| 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 |
201205
| 5.5 | GitHub Issues import | ❌ Not started | #563–565 |
202206

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,6 @@ module.exports = {
7575
MoneyBag02Icon: createIconMock('MoneyBag02Icon'),
7676
Analytics01Icon: createIconMock('Analytics01Icon'),
7777
ChartLineData01Icon: createIconMock('ChartLineData01Icon'),
78+
// NotificationCenter
79+
Notification02Icon: createIconMock('Notification02Icon'),
7880
};

web-ui/__tests__/app/proof/page.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ jest.mock('@/lib/workspace-storage', () => ({
1515

1616
jest.mock('swr', () => ({ __esModule: true, default: jest.fn() }));
1717

18+
// Stub NotificationContext — gate-failure dispatch is exercised in its own tests.
19+
jest.mock('@/contexts/NotificationContext', () => ({
20+
useNotificationContext: () => ({
21+
notifications: [],
22+
unreadCount: 0,
23+
addNotification: jest.fn(),
24+
markRead: jest.fn(),
25+
markAllRead: jest.fn(),
26+
clearAll: jest.fn(),
27+
}),
28+
NotificationProvider: ({ children }: { children: React.ReactNode }) => children,
29+
}));
30+
1831
import useSWR from 'swr';
1932
import { proofApi } from '@/lib/api';
2033

web-ui/__tests__/components/layout/AppSidebar.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ jest.mock('@/components/proof', () => ({
3131
open ? <div data-testid="capture-modal">CaptureGlitchModal</div> : null,
3232
}));
3333

34+
// Mock NotificationCenter — it requires NotificationProvider context, which
35+
// this isolated sidebar test does not provide. The component has its own tests.
36+
jest.mock('@/components/layout/NotificationCenter', () => ({
37+
NotificationCenter: () => <div data-testid="notification-center" />,
38+
}));
39+
3440
// Mock SWR (used for blocker + session badge counts)
3541
const mockSWRData: Record<string, unknown> = {};
3642
jest.mock('swr', () => ({
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Verifies the execution landing page requests Notification permission
3+
* exactly once, only when permission is 'default', and not at all when
4+
* already granted or denied. (#559 acceptance criterion: "Permission
5+
* request shown once and respected".)
6+
*/
7+
import { render } from '@testing-library/react';
8+
import ExecutionLandingPage from '@/app/execution/page';
9+
10+
jest.mock('@/lib/workspace-storage', () => ({
11+
getSelectedWorkspacePath: jest.fn(() => null),
12+
}));
13+
jest.mock('next/navigation', () => ({
14+
useSearchParams: () => new URLSearchParams(),
15+
useRouter: () => ({ replace: jest.fn(), push: jest.fn() }),
16+
}));
17+
jest.mock('@/components/execution/BatchExecutionMonitor', () => ({
18+
BatchExecutionMonitor: () => null,
19+
}));
20+
21+
let currentPermission: NotificationPermission = 'default';
22+
const requestPermissionMock = jest.fn().mockResolvedValue('granted');
23+
24+
function installNotificationStub() {
25+
const stub = function () {} as unknown as typeof Notification;
26+
Object.defineProperty(stub, 'permission', { configurable: true, get: () => currentPermission });
27+
Object.defineProperty(stub, 'requestPermission', { configurable: true, value: requestPermissionMock });
28+
(global as unknown as { Notification: typeof Notification }).Notification = stub;
29+
}
30+
31+
beforeEach(() => {
32+
requestPermissionMock.mockClear();
33+
currentPermission = 'default';
34+
installNotificationStub();
35+
});
36+
37+
describe('ExecutionLandingPage permission request', () => {
38+
it('calls Notification.requestPermission once when permission is default', () => {
39+
render(<ExecutionLandingPage />);
40+
expect(requestPermissionMock).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('does NOT request permission when already granted', () => {
44+
currentPermission = 'granted';
45+
render(<ExecutionLandingPage />);
46+
expect(requestPermissionMock).not.toHaveBeenCalled();
47+
});
48+
49+
it('does NOT request permission when already denied', () => {
50+
currentPermission = 'denied';
51+
render(<ExecutionLandingPage />);
52+
expect(requestPermissionMock).not.toHaveBeenCalled();
53+
});
54+
});
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { render, screen, fireEvent, within } from '@testing-library/react';
2+
import { NotificationCenter } from '@/components/layout/NotificationCenter';
3+
import { NotificationProvider } from '@/contexts/NotificationContext';
4+
import { useNotificationContext } from '@/contexts/NotificationContext';
5+
import { NOTIFICATIONS_STORAGE_KEY } from '@/hooks/useNotifications';
6+
7+
// Test harness — lets us add notifications from outside the component tree.
8+
function Harness({ children }: { children: React.ReactNode }) {
9+
return <NotificationProvider>{children}</NotificationProvider>;
10+
}
11+
12+
function Adder({ buttonId, payload }: { buttonId: string; payload: { type: 'batch.completed' | 'blocker.created' | 'gate.run.failed'; message: string } }) {
13+
const { addNotification } = useNotificationContext();
14+
return (
15+
<button data-testid={buttonId} onClick={() => addNotification(payload)}>
16+
add
17+
</button>
18+
);
19+
}
20+
21+
beforeEach(() => {
22+
localStorage.clear();
23+
});
24+
25+
describe('NotificationCenter', () => {
26+
it('renders a bell button with no badge when there are no unread notifications', () => {
27+
render(
28+
<Harness>
29+
<NotificationCenter />
30+
</Harness>
31+
);
32+
const bell = screen.getByRole('button', { name: /notifications/i });
33+
expect(bell).toBeInTheDocument();
34+
expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
35+
});
36+
37+
it('shows an unread badge when there are unread notifications', () => {
38+
render(
39+
<Harness>
40+
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'Batch 1 done' }} />
41+
<NotificationCenter />
42+
</Harness>
43+
);
44+
fireEvent.click(screen.getByTestId('add-1'));
45+
expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
46+
});
47+
48+
it('opens the dropdown and lists notifications when the bell is clicked', () => {
49+
render(
50+
<Harness>
51+
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'Batch 1 done' }} />
52+
<Adder buttonId="add-2" payload={{ type: 'blocker.created', message: 'Blocked: task 7' }} />
53+
<NotificationCenter />
54+
</Harness>
55+
);
56+
57+
fireEvent.click(screen.getByTestId('add-1'));
58+
fireEvent.click(screen.getByTestId('add-2'));
59+
60+
expect(screen.queryByText(/Batch 1 done/)).not.toBeInTheDocument();
61+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
62+
63+
expect(screen.getByText(/Batch 1 done/)).toBeInTheDocument();
64+
expect(screen.getByText(/Blocked: task 7/)).toBeInTheDocument();
65+
});
66+
67+
it('shows empty state when there are no notifications', () => {
68+
render(
69+
<Harness>
70+
<NotificationCenter />
71+
</Harness>
72+
);
73+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
74+
expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
75+
});
76+
77+
it('marks a single notification read via its X button', () => {
78+
render(
79+
<Harness>
80+
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'msg-x' }} />
81+
<NotificationCenter />
82+
</Harness>
83+
);
84+
fireEvent.click(screen.getByTestId('add-1'));
85+
expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
86+
87+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
88+
const item = screen.getByText(/msg-x/).closest('[data-testid="notification-item"]');
89+
expect(item).toBeTruthy();
90+
const markBtn = within(item as HTMLElement).getByRole('button', { name: /mark as read/i });
91+
fireEvent.click(markBtn);
92+
93+
expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
94+
});
95+
96+
it('marks all notifications read', () => {
97+
render(
98+
<Harness>
99+
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'a' }} />
100+
<Adder buttonId="add-2" payload={{ type: 'blocker.created', message: 'b' }} />
101+
<NotificationCenter />
102+
</Harness>
103+
);
104+
fireEvent.click(screen.getByTestId('add-1'));
105+
fireEvent.click(screen.getByTestId('add-2'));
106+
expect(screen.getByTestId('notification-badge')).toHaveTextContent('2');
107+
108+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
109+
fireEvent.click(screen.getByRole('button', { name: /mark all read/i }));
110+
111+
expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
112+
});
113+
114+
it('clears all notifications', () => {
115+
render(
116+
<Harness>
117+
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'a' }} />
118+
<NotificationCenter />
119+
</Harness>
120+
);
121+
fireEvent.click(screen.getByTestId('add-1'));
122+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
123+
fireEvent.click(screen.getByRole('button', { name: /clear all/i }));
124+
125+
expect(screen.queryByText(/^a$/)).not.toBeInTheDocument();
126+
expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
127+
});
128+
129+
it('does not render a green checkmark for a FAILED batch notification', () => {
130+
// Regression for codex review finding: FAILED/CANCELLED must not look like success.
131+
const stored = [
132+
{
133+
id: '1',
134+
type: 'batch.completed',
135+
batchStatus: 'FAILED',
136+
message: 'Batch X failed — 2/5 tasks completed before failure',
137+
timestamp: new Date().toISOString(),
138+
read: false,
139+
},
140+
];
141+
localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(stored));
142+
143+
render(
144+
<Harness>
145+
<NotificationCenter />
146+
</Harness>
147+
);
148+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
149+
const item = screen.getByText(/Batch X failed/).closest('[data-testid="notification-item"]');
150+
expect(item).toBeTruthy();
151+
// The success icon must not be present in a failed-batch row
152+
expect(within(item as HTMLElement).queryByTestId('icon-CheckmarkCircle01Icon')).toBeNull();
153+
});
154+
155+
it('closes the dropdown when Escape is pressed', () => {
156+
render(
157+
<Harness>
158+
<NotificationCenter />
159+
</Harness>
160+
);
161+
const bell = screen.getByRole('button', { name: /notifications/i });
162+
fireEvent.click(bell);
163+
expect(bell).toHaveAttribute('aria-expanded', 'true');
164+
165+
fireEvent.keyDown(document, { key: 'Escape' });
166+
expect(bell).toHaveAttribute('aria-expanded', 'false');
167+
});
168+
169+
it('exposes aria-expanded and aria-controls on the bell button', () => {
170+
render(
171+
<Harness>
172+
<NotificationCenter />
173+
</Harness>
174+
);
175+
const bell = screen.getByRole('button', { name: /notifications/i });
176+
expect(bell).toHaveAttribute('aria-expanded', 'false');
177+
expect(bell).toHaveAttribute('aria-controls', 'notification-popover');
178+
179+
fireEvent.click(bell);
180+
expect(bell).toHaveAttribute('aria-expanded', 'true');
181+
expect(document.getElementById('notification-popover')).toBeInTheDocument();
182+
});
183+
184+
it('renders notifications from existing localStorage state on mount', () => {
185+
const stored = [
186+
{
187+
id: '1',
188+
type: 'gate.run.failed',
189+
message: 'gate failed: unit',
190+
timestamp: new Date().toISOString(),
191+
read: false,
192+
},
193+
];
194+
localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(stored));
195+
196+
render(
197+
<Harness>
198+
<NotificationCenter />
199+
</Harness>
200+
);
201+
expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
202+
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
203+
expect(screen.getByText(/gate failed: unit/)).toBeInTheDocument();
204+
});
205+
});

web-ui/src/__tests__/components/proof/ProofPage.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ jest.mock('@/lib/api', () => ({
2020
waiveRequirement: jest.fn(),
2121
},
2222
}));
23+
jest.mock('@/contexts/NotificationContext', () => ({
24+
useNotificationContext: () => ({
25+
notifications: [],
26+
unreadCount: 0,
27+
addNotification: jest.fn(),
28+
markRead: jest.fn(),
29+
markAllRead: jest.fn(),
30+
clearAll: jest.fn(),
31+
}),
32+
NotificationProvider: ({ children }: { children: React.ReactNode }) => children,
33+
}));
2334
jest.mock('@/components/proof', () => ({
2435
ProofStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
2536
WaiveDialog: () => null,

0 commit comments

Comments
 (0)