diff --git a/CLAUDE.md b/CLAUDE.md
index 8975c4e0..16bd5e82 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,6 +36,8 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol
### Current Focus: Phase 4A
+**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.
+
**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`.
**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`.
diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md
index 03bc126f..4f5ff5e3 100644
--- a/docs/PRODUCT_ROADMAP.md
+++ b/docs/PRODUCT_ROADMAP.md
@@ -126,15 +126,19 @@ Without a settings page, a new user who cannot find the env vars cannot use the
### 3. Async Notifications
-**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.
+**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.
-**What to build**:
+**What was built (#559)**:
-- **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
-- **In-app notification center**: a bell icon in the sidebar with a history of recent notifications, clearable
-- **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
+- **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
+- **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
+- **Permission request**: fires once on first visit to `/execution` only when permission state is `default`
+
+**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.
-The webhook is optional and last priority. Browser notifications and the in-app center are sufficient for the core use case.
+**What's still planned (#560)**:
+
+- **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
---
@@ -196,7 +200,7 @@ These are items that were considered and excluded because they do not serve the
| 4B | Post-merge glitch capture loop | ❌ Not started | — |
| 5.1 | Settings page (skeleton + agent config + PROOF9/workspace tabs) | ✅ Complete | #554–556 |
| 5.2 | Cost analytics | ✅ Complete | #557–558 |
-| 5.3 | Async notifications | ❌ Not started | #559–560 |
+| 5.3 | Async notifications | 🚧 Browser + in-app center shipped (#559); webhook (#560) deferred | #559–560 |
| 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 |
| 5.5 | GitHub Issues import | ❌ Not started | #563–565 |
diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js
index d7e97eec..34f1a5d0 100644
--- a/web-ui/__mocks__/@hugeicons/react.js
+++ b/web-ui/__mocks__/@hugeicons/react.js
@@ -75,4 +75,6 @@ module.exports = {
MoneyBag02Icon: createIconMock('MoneyBag02Icon'),
Analytics01Icon: createIconMock('Analytics01Icon'),
ChartLineData01Icon: createIconMock('ChartLineData01Icon'),
+ // NotificationCenter
+ Notification02Icon: createIconMock('Notification02Icon'),
};
diff --git a/web-ui/__tests__/app/proof/page.test.tsx b/web-ui/__tests__/app/proof/page.test.tsx
index 054e40f1..522091fb 100644
--- a/web-ui/__tests__/app/proof/page.test.tsx
+++ b/web-ui/__tests__/app/proof/page.test.tsx
@@ -15,6 +15,19 @@ jest.mock('@/lib/workspace-storage', () => ({
jest.mock('swr', () => ({ __esModule: true, default: jest.fn() }));
+// Stub NotificationContext — gate-failure dispatch is exercised in its own tests.
+jest.mock('@/contexts/NotificationContext', () => ({
+ useNotificationContext: () => ({
+ notifications: [],
+ unreadCount: 0,
+ addNotification: jest.fn(),
+ markRead: jest.fn(),
+ markAllRead: jest.fn(),
+ clearAll: jest.fn(),
+ }),
+ NotificationProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
import useSWR from 'swr';
import { proofApi } from '@/lib/api';
diff --git a/web-ui/__tests__/components/layout/AppSidebar.test.tsx b/web-ui/__tests__/components/layout/AppSidebar.test.tsx
index 205ff8c6..2fe98e42 100644
--- a/web-ui/__tests__/components/layout/AppSidebar.test.tsx
+++ b/web-ui/__tests__/components/layout/AppSidebar.test.tsx
@@ -31,6 +31,12 @@ jest.mock('@/components/proof', () => ({
open ?
CaptureGlitchModal
: null,
}));
+// Mock NotificationCenter — it requires NotificationProvider context, which
+// this isolated sidebar test does not provide. The component has its own tests.
+jest.mock('@/components/layout/NotificationCenter', () => ({
+ NotificationCenter: () => ,
+}));
+
// Mock SWR (used for blocker + session badge counts)
const mockSWRData: Record = {};
jest.mock('swr', () => ({
diff --git a/web-ui/src/__tests__/app/execution/permission-request.test.tsx b/web-ui/src/__tests__/app/execution/permission-request.test.tsx
new file mode 100644
index 00000000..2247e06c
--- /dev/null
+++ b/web-ui/src/__tests__/app/execution/permission-request.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * Verifies the execution landing page requests Notification permission
+ * exactly once, only when permission is 'default', and not at all when
+ * already granted or denied. (#559 acceptance criterion: "Permission
+ * request shown once and respected".)
+ */
+import { render } from '@testing-library/react';
+import ExecutionLandingPage from '@/app/execution/page';
+
+jest.mock('@/lib/workspace-storage', () => ({
+ getSelectedWorkspacePath: jest.fn(() => null),
+}));
+jest.mock('next/navigation', () => ({
+ useSearchParams: () => new URLSearchParams(),
+ useRouter: () => ({ replace: jest.fn(), push: jest.fn() }),
+}));
+jest.mock('@/components/execution/BatchExecutionMonitor', () => ({
+ BatchExecutionMonitor: () => null,
+}));
+
+let currentPermission: NotificationPermission = 'default';
+const requestPermissionMock = jest.fn().mockResolvedValue('granted');
+
+function installNotificationStub() {
+ const stub = function () {} as unknown as typeof Notification;
+ Object.defineProperty(stub, 'permission', { configurable: true, get: () => currentPermission });
+ Object.defineProperty(stub, 'requestPermission', { configurable: true, value: requestPermissionMock });
+ (global as unknown as { Notification: typeof Notification }).Notification = stub;
+}
+
+beforeEach(() => {
+ requestPermissionMock.mockClear();
+ currentPermission = 'default';
+ installNotificationStub();
+});
+
+describe('ExecutionLandingPage permission request', () => {
+ it('calls Notification.requestPermission once when permission is default', () => {
+ render();
+ expect(requestPermissionMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does NOT request permission when already granted', () => {
+ currentPermission = 'granted';
+ render();
+ expect(requestPermissionMock).not.toHaveBeenCalled();
+ });
+
+ it('does NOT request permission when already denied', () => {
+ currentPermission = 'denied';
+ render();
+ expect(requestPermissionMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/web-ui/src/__tests__/components/layout/NotificationCenter.test.tsx b/web-ui/src/__tests__/components/layout/NotificationCenter.test.tsx
new file mode 100644
index 00000000..73076e7e
--- /dev/null
+++ b/web-ui/src/__tests__/components/layout/NotificationCenter.test.tsx
@@ -0,0 +1,205 @@
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import { NotificationCenter } from '@/components/layout/NotificationCenter';
+import { NotificationProvider } from '@/contexts/NotificationContext';
+import { useNotificationContext } from '@/contexts/NotificationContext';
+import { NOTIFICATIONS_STORAGE_KEY } from '@/hooks/useNotifications';
+
+// Test harness — lets us add notifications from outside the component tree.
+function Harness({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+function Adder({ buttonId, payload }: { buttonId: string; payload: { type: 'batch.completed' | 'blocker.created' | 'gate.run.failed'; message: string } }) {
+ const { addNotification } = useNotificationContext();
+ return (
+
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+describe('NotificationCenter', () => {
+ it('renders a bell button with no badge when there are no unread notifications', () => {
+ render(
+
+
+
+ );
+ const bell = screen.getByRole('button', { name: /notifications/i });
+ expect(bell).toBeInTheDocument();
+ expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
+ });
+
+ it('shows an unread badge when there are unread notifications', () => {
+ render(
+
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('add-1'));
+ expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
+ });
+
+ it('opens the dropdown and lists notifications when the bell is clicked', () => {
+ render(
+
+
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('add-1'));
+ fireEvent.click(screen.getByTestId('add-2'));
+
+ expect(screen.queryByText(/Batch 1 done/)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(screen.getByText(/Batch 1 done/)).toBeInTheDocument();
+ expect(screen.getByText(/Blocked: task 7/)).toBeInTheDocument();
+ });
+
+ it('shows empty state when there are no notifications', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
+ });
+
+ it('marks a single notification read via its X button', () => {
+ render(
+
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('add-1'));
+ expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
+
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ const item = screen.getByText(/msg-x/).closest('[data-testid="notification-item"]');
+ expect(item).toBeTruthy();
+ const markBtn = within(item as HTMLElement).getByRole('button', { name: /mark as read/i });
+ fireEvent.click(markBtn);
+
+ expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
+ });
+
+ it('marks all notifications read', () => {
+ render(
+
+
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('add-1'));
+ fireEvent.click(screen.getByTestId('add-2'));
+ expect(screen.getByTestId('notification-badge')).toHaveTextContent('2');
+
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ fireEvent.click(screen.getByRole('button', { name: /mark all read/i }));
+
+ expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument();
+ });
+
+ it('clears all notifications', () => {
+ render(
+
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('add-1'));
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ fireEvent.click(screen.getByRole('button', { name: /clear all/i }));
+
+ expect(screen.queryByText(/^a$/)).not.toBeInTheDocument();
+ expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
+ });
+
+ it('does not render a green checkmark for a FAILED batch notification', () => {
+ // Regression for codex review finding: FAILED/CANCELLED must not look like success.
+ const stored = [
+ {
+ id: '1',
+ type: 'batch.completed',
+ batchStatus: 'FAILED',
+ message: 'Batch X failed — 2/5 tasks completed before failure',
+ timestamp: new Date().toISOString(),
+ read: false,
+ },
+ ];
+ localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(stored));
+
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ const item = screen.getByText(/Batch X failed/).closest('[data-testid="notification-item"]');
+ expect(item).toBeTruthy();
+ // The success icon must not be present in a failed-batch row
+ expect(within(item as HTMLElement).queryByTestId('icon-CheckmarkCircle01Icon')).toBeNull();
+ });
+
+ it('closes the dropdown when Escape is pressed', () => {
+ render(
+
+
+
+ );
+ const bell = screen.getByRole('button', { name: /notifications/i });
+ fireEvent.click(bell);
+ expect(bell).toHaveAttribute('aria-expanded', 'true');
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(bell).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('exposes aria-expanded and aria-controls on the bell button', () => {
+ render(
+
+
+
+ );
+ const bell = screen.getByRole('button', { name: /notifications/i });
+ expect(bell).toHaveAttribute('aria-expanded', 'false');
+ expect(bell).toHaveAttribute('aria-controls', 'notification-popover');
+
+ fireEvent.click(bell);
+ expect(bell).toHaveAttribute('aria-expanded', 'true');
+ expect(document.getElementById('notification-popover')).toBeInTheDocument();
+ });
+
+ it('renders notifications from existing localStorage state on mount', () => {
+ const stored = [
+ {
+ id: '1',
+ type: 'gate.run.failed',
+ message: 'gate failed: unit',
+ timestamp: new Date().toISOString(),
+ read: false,
+ },
+ ];
+ localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(stored));
+
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ expect(screen.getByText(/gate failed: unit/)).toBeInTheDocument();
+ });
+});
diff --git a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
index 92052d8d..7bd4621b 100644
--- a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
+++ b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
@@ -20,6 +20,17 @@ jest.mock('@/lib/api', () => ({
waiveRequirement: jest.fn(),
},
}));
+jest.mock('@/contexts/NotificationContext', () => ({
+ useNotificationContext: () => ({
+ notifications: [],
+ unreadCount: 0,
+ addNotification: jest.fn(),
+ markRead: jest.fn(),
+ markAllRead: jest.fn(),
+ clearAll: jest.fn(),
+ }),
+ NotificationProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
jest.mock('@/components/proof', () => ({
ProofStatusBadge: ({ status }: { status: string }) => {status},
WaiveDialog: () => null,
diff --git a/web-ui/src/__tests__/hooks/useNotifications.test.ts b/web-ui/src/__tests__/hooks/useNotifications.test.ts
new file mode 100644
index 00000000..6ed0527c
--- /dev/null
+++ b/web-ui/src/__tests__/hooks/useNotifications.test.ts
@@ -0,0 +1,337 @@
+import { renderHook, act } from '@testing-library/react';
+import {
+ useNotifications,
+ NOTIFICATIONS_STORAGE_KEY,
+ NOTIFICATIONS_STORAGE_KEY_PREFIX,
+ MAX_NOTIFICATIONS,
+} from '@/hooks/useNotifications';
+import { setSelectedWorkspacePath, clearSelectedWorkspacePath } from '@/lib/workspace-storage';
+
+// Helper to mock Notification API + visibility
+type PermissionState = NotificationPermission;
+
+let currentPermission: PermissionState = 'default';
+
+function setNotificationPermission(value: PermissionState) {
+ currentPermission = value;
+}
+
+function setVisibility(value: DocumentVisibilityState) {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => value,
+ });
+}
+
+const NotificationCtor = jest.fn();
+
+// Stub the Notification global with a function plus a `permission` getter.
+function installNotificationStub() {
+ const stub = function (this: unknown, title: string, options?: NotificationOptions) {
+ NotificationCtor(title, options);
+ } as unknown as typeof Notification;
+ Object.defineProperty(stub, 'permission', {
+ configurable: true,
+ get: () => currentPermission,
+ });
+ Object.defineProperty(stub, 'requestPermission', {
+ configurable: true,
+ value: jest.fn().mockResolvedValue('granted'),
+ });
+ (global as unknown as { Notification: typeof Notification }).Notification = stub;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ NotificationCtor.mockClear();
+ currentPermission = 'default';
+ installNotificationStub();
+ setVisibility('visible');
+});
+
+describe('useNotifications', () => {
+ it('starts empty when no localStorage entry exists', () => {
+ const { result } = renderHook(() => useNotifications());
+ expect(result.current.notifications).toEqual([]);
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('hydrates from localStorage on mount', () => {
+ const stored = [
+ {
+ id: '1',
+ type: 'batch.completed',
+ message: 'Batch finished',
+ timestamp: new Date().toISOString(),
+ read: false,
+ },
+ ];
+ localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(stored));
+ const { result } = renderHook(() => useNotifications());
+ expect(result.current.notifications).toHaveLength(1);
+ expect(result.current.notifications[0].id).toBe('1');
+ expect(result.current.unreadCount).toBe(1);
+ });
+
+ it('prepends a new notification (newest first) and persists', () => {
+ const { result } = renderHook(() => useNotifications());
+
+ act(() => {
+ result.current.addNotification({
+ type: 'batch.completed',
+ message: 'Batch 1 done',
+ });
+ });
+
+ expect(result.current.notifications).toHaveLength(1);
+ expect(result.current.notifications[0].message).toBe('Batch 1 done');
+ expect(result.current.notifications[0].read).toBe(false);
+
+ act(() => {
+ result.current.addNotification({
+ type: 'blocker.created',
+ message: 'Blocked',
+ });
+ });
+
+ expect(result.current.notifications).toHaveLength(2);
+ expect(result.current.notifications[0].message).toBe('Blocked');
+ expect(result.current.notifications[1].message).toBe('Batch 1 done');
+
+ const persisted = JSON.parse(localStorage.getItem(NOTIFICATIONS_STORAGE_KEY)!);
+ expect(persisted).toHaveLength(2);
+ });
+
+ it(`caps history at ${MAX_NOTIFICATIONS} entries`, () => {
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ for (let i = 0; i < MAX_NOTIFICATIONS + 5; i++) {
+ result.current.addNotification({
+ type: 'batch.completed',
+ message: `msg ${i}`,
+ });
+ }
+ });
+ expect(result.current.notifications).toHaveLength(MAX_NOTIFICATIONS);
+ // Newest first → first element should be the last we added
+ expect(result.current.notifications[0].message).toBe(`msg ${MAX_NOTIFICATIONS + 4}`);
+ });
+
+ it('marks a single notification as read', () => {
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'a' });
+ result.current.addNotification({ type: 'batch.completed', message: 'b' });
+ });
+ expect(result.current.unreadCount).toBe(2);
+
+ const firstId = result.current.notifications[0].id;
+ act(() => {
+ result.current.markRead(firstId);
+ });
+ expect(result.current.unreadCount).toBe(1);
+ expect(result.current.notifications.find((n) => n.id === firstId)?.read).toBe(true);
+ });
+
+ it('marks all notifications as read', () => {
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'a' });
+ result.current.addNotification({ type: 'blocker.created', message: 'b' });
+ });
+ act(() => {
+ result.current.markAllRead();
+ });
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('clears all notifications and persists empty state', () => {
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'a' });
+ });
+ expect(result.current.notifications).toHaveLength(1);
+
+ act(() => {
+ result.current.clearAll();
+ });
+ expect(result.current.notifications).toHaveLength(0);
+ const persisted = JSON.parse(localStorage.getItem(NOTIFICATIONS_STORAGE_KEY)!);
+ expect(persisted).toEqual([]);
+ });
+
+ it('fires a browser Notification when tab is hidden and permission is granted', () => {
+ setVisibility('hidden');
+ setNotificationPermission('granted');
+
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({
+ type: 'batch.completed',
+ message: 'Batch 7 done',
+ });
+ });
+
+ expect(NotificationCtor).toHaveBeenCalledTimes(1);
+ const [title, options] = NotificationCtor.mock.calls[0];
+ expect(typeof title).toBe('string');
+ expect(options.body).toBe('Batch 7 done');
+ });
+
+ it('does NOT fire a browser Notification when tab is visible', () => {
+ setVisibility('visible');
+ setNotificationPermission('granted');
+
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({
+ type: 'batch.completed',
+ message: 'Batch 8 done',
+ });
+ });
+ expect(NotificationCtor).not.toHaveBeenCalled();
+ });
+
+ it('does NOT fire a browser Notification when permission is denied', () => {
+ setVisibility('hidden');
+ setNotificationPermission('denied');
+
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({
+ type: 'batch.completed',
+ message: 'Batch 9 done',
+ });
+ });
+ expect(NotificationCtor).not.toHaveBeenCalled();
+ });
+
+ it('does not crash when localStorage.setItem throws', () => {
+ // Simulate quota-exceeded / private-mode by stubbing setItem
+ const original = Storage.prototype.setItem;
+ const consoleErr = jest.spyOn(console, 'error').mockImplementation(() => {});
+ Storage.prototype.setItem = jest.fn(() => {
+ throw new Error('QuotaExceededError');
+ });
+
+ try {
+ const { result } = renderHook(() => useNotifications());
+ expect(() => {
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'should not crash' });
+ });
+ }).not.toThrow();
+ // The in-memory state still reflects the update even though persist failed
+ expect(result.current.notifications).toHaveLength(1);
+ expect(consoleErr).toHaveBeenCalled();
+ } finally {
+ Storage.prototype.setItem = original;
+ consoleErr.mockRestore();
+ }
+ });
+
+ it('persists batchStatus on the stored notification', () => {
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({
+ type: 'batch.completed',
+ batchStatus: 'FAILED',
+ message: 'Batch X failed — 2/5 tasks completed before failure',
+ });
+ });
+ expect(result.current.notifications[0].batchStatus).toBe('FAILED');
+ const persisted = JSON.parse(localStorage.getItem(NOTIFICATIONS_STORAGE_KEY)!);
+ expect(persisted[0].batchStatus).toBe('FAILED');
+ });
+
+ describe('workspace scoping', () => {
+ afterEach(() => {
+ clearSelectedWorkspacePath();
+ });
+
+ it('stores notifications under a workspace-scoped key when a workspace is selected', () => {
+ setSelectedWorkspacePath('/workspace/A');
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'workspace A msg' });
+ });
+
+ const wsKey = `${NOTIFICATIONS_STORAGE_KEY_PREFIX}_${encodeURIComponent('/workspace/A')}`;
+ const persisted = JSON.parse(localStorage.getItem(wsKey)!);
+ expect(persisted).toHaveLength(1);
+ expect(persisted[0].message).toBe('workspace A msg');
+ expect(persisted[0].workspacePath).toBe('/workspace/A');
+ // The global key must remain untouched
+ expect(localStorage.getItem(NOTIFICATIONS_STORAGE_KEY)).toBeNull();
+ });
+
+ it('ignores cross-tab storage events for unrelated keys', () => {
+ setSelectedWorkspacePath('/workspace/A');
+ const wsKey = `${NOTIFICATIONS_STORAGE_KEY_PREFIX}_${encodeURIComponent('/workspace/A')}`;
+ const { result } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'A1' });
+ });
+ expect(result.current.notifications).toHaveLength(1);
+
+ // Simulate another tab writing to an unrelated localStorage key.
+ // Our hook must not reload from storage and overwrite in-memory state.
+ const beforeRef = result.current.notifications;
+ act(() => {
+ window.dispatchEvent(
+ new StorageEvent('storage', { key: 'some_other_app_key', newValue: 'x' })
+ );
+ });
+ // Same reference => no state update happened.
+ expect(result.current.notifications).toBe(beforeRef);
+
+ // But cross-tab writes to the notification key DO trigger reload.
+ const remoteEntry = [{
+ id: 'remote',
+ type: 'batch.completed',
+ message: 'from Tab B',
+ timestamp: new Date().toISOString(),
+ read: false,
+ }];
+ act(() => {
+ localStorage.setItem(wsKey, JSON.stringify(remoteEntry));
+ window.dispatchEvent(
+ new StorageEvent('storage', { key: wsKey, newValue: JSON.stringify(remoteEntry) })
+ );
+ });
+ expect(result.current.notifications).toHaveLength(1);
+ expect(result.current.notifications[0].id).toBe('remote');
+ });
+
+ it('does not leak notifications across workspaces', () => {
+ setSelectedWorkspacePath('/workspace/A');
+ const { result, rerender } = renderHook(() => useNotifications());
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'A1' });
+ result.current.addNotification({ type: 'blocker.created', message: 'A2' });
+ });
+ expect(result.current.notifications).toHaveLength(2);
+
+ // Switch workspaces
+ act(() => {
+ setSelectedWorkspacePath('/workspace/B');
+ });
+ rerender();
+ expect(result.current.notifications).toHaveLength(0);
+
+ act(() => {
+ result.current.addNotification({ type: 'batch.completed', message: 'B1' });
+ });
+ expect(result.current.notifications).toHaveLength(1);
+ expect(result.current.notifications[0].message).toBe('B1');
+
+ // Switch back — workspace A notifications should still be there
+ act(() => {
+ setSelectedWorkspacePath('/workspace/A');
+ });
+ rerender();
+ expect(result.current.notifications).toHaveLength(2);
+ expect(result.current.notifications[0].message).toBe('A2');
+ });
+ });
+});
diff --git a/web-ui/src/app/execution/page.tsx b/web-ui/src/app/execution/page.tsx
index ab6ffc07..6e06fea5 100644
--- a/web-ui/src/app/execution/page.tsx
+++ b/web-ui/src/app/execution/page.tsx
@@ -53,6 +53,15 @@ function ExecutionLandingContent() {
setWorkspaceReady(true);
}, []);
+ // Request browser notification permission once on first visit
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof Notification === 'undefined') return;
+ if (Notification.permission === 'default') {
+ // Fire-and-forget — browsers handle the UI; we honor whatever the user picks.
+ Notification.requestPermission().catch(() => {});
+ }
+ }, []);
+
// If ?task= is present, redirect immediately
useEffect(() => {
if (taskIdParam) {
diff --git a/web-ui/src/app/layout.tsx b/web-ui/src/app/layout.tsx
index aeecae36..1d763791 100644
--- a/web-ui/src/app/layout.tsx
+++ b/web-ui/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { Nunito_Sans } from 'next/font/google';
import { Toaster } from 'sonner';
import { AppLayout } from '@/components/layout';
+import { NotificationProvider } from '@/contexts/NotificationContext';
import './globals.css';
const nunitoSans = Nunito_Sans({
@@ -31,8 +32,10 @@ export default function RootLayout({
return (
- {children}
-
+
+ {children}
+
+
);
diff --git a/web-ui/src/app/proof/page.tsx b/web-ui/src/app/proof/page.tsx
index f5db6219..e4534888 100644
--- a/web-ui/src/app/proof/page.tsx
+++ b/web-ui/src/app/proof/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, useMemo, Suspense } from 'react';
+import { useState, useEffect, useMemo, useRef, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner, RunHistoryPanel, GateEvidencePanel, CaptureGlitchModal } from '@/components/proof';
import { proofApi } from '@/lib/api';
import { useProofRun } from '@/hooks/useProofRun';
+import { useNotificationContext } from '@/contexts/NotificationContext';
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
import type { ProofRequirement, ProofRequirementListResponse, ProofReqStatus, ProofSeverity, ProofRunDetail } from '@/types';
@@ -107,6 +108,31 @@ function ProofPageContent() {
const [selectedRunId, setSelectedRunId] = useState(null);
const { runState, gateEntries, passed, runMessage, errorMessage, startRun, retry } = useProofRun();
+ const { addNotification } = useNotificationContext();
+
+ // Notify on each gate failure when a run completes with passed=false.
+ // Track the gateEntries array we already dispatched for so we don't re-fire
+ // notifications when an unrelated dep changes (e.g. workspace switch → new
+ // addNotification reference) while the completed run is still in state.
+ const dispatchedGateEntriesRef = useRef(null);
+ useEffect(() => {
+ if (runState === 'idle') {
+ dispatchedGateEntriesRef.current = null;
+ return;
+ }
+ if (runState !== 'complete' || passed !== false) return;
+ if (dispatchedGateEntriesRef.current === gateEntries) return;
+ dispatchedGateEntriesRef.current = gateEntries;
+ for (const entry of gateEntries) {
+ if (entry.status === 'failed') {
+ addNotification({
+ type: 'gate.run.failed',
+ message: `Gate run failed: ${entry.gate}`,
+ gateName: entry.gate,
+ });
+ }
+ }
+ }, [runState, passed, gateEntries, addNotification]);
// Sort state (default: status asc → open first, then severity)
const [sortCol, setSortCol] = useState('status');
diff --git a/web-ui/src/components/execution/BatchExecutionMonitor.tsx b/web-ui/src/components/execution/BatchExecutionMonitor.tsx
index cf560bd9..d758de1f 100644
--- a/web-ui/src/components/execution/BatchExecutionMonitor.tsx
+++ b/web-ui/src/components/execution/BatchExecutionMonitor.tsx
@@ -24,6 +24,7 @@ import {
import { batchesApi, tasksApi } from '@/lib/api';
import { EventStream } from './EventStream';
import { useExecutionMonitor } from '@/hooks/useExecutionMonitor';
+import { useNotificationContext } from '@/contexts/NotificationContext';
import type { BatchResponse, Task } from '@/types';
// ── Status icon helper ────────────────────────────────────────────────
@@ -50,6 +51,7 @@ interface BatchExecutionMonitorProps {
export function BatchExecutionMonitor({ batchId, workspacePath }: BatchExecutionMonitorProps) {
const router = useRouter();
+ const { addNotification } = useNotificationContext();
const [batch, setBatch] = useState(null);
const [tasks, setTasks] = useState>({});
const [expandedTaskId, setExpandedTaskId] = useState(null);
@@ -59,6 +61,10 @@ export function BatchExecutionMonitor({ batchId, workspacePath }: BatchExecution
// Track which task IDs have already been fetched to avoid refetching
const fetchedTaskIdsRef = useRef>(new Set());
+ // Track previous batch + per-task statuses for transition-based notifications
+ const prevBatchStatusRef = useRef(null);
+ const prevTaskStatusesRef = useRef>({});
+
// ── Fetch batch details + task names ────────────────────────────────
const fetchBatch = useCallback(async () => {
try {
@@ -98,6 +104,56 @@ export function BatchExecutionMonitor({ batchId, workspacePath }: BatchExecution
};
}, [batch?.status, fetchBatch]);
+ // Fire notifications on batch terminal transition + per-task BLOCKED transitions
+ useEffect(() => {
+ if (!batch) return;
+
+ const TERMINAL = ['COMPLETED', 'FAILED', 'CANCELLED'];
+ const prevBatchStatus = prevBatchStatusRef.current;
+ if (
+ prevBatchStatus !== null &&
+ !TERMINAL.includes(prevBatchStatus) &&
+ TERMINAL.includes(batch.status)
+ ) {
+ const completedCount = batch.task_ids.filter(
+ (id) => batch.results[id] === 'COMPLETED' || batch.results[id] === 'DONE'
+ ).length;
+ const total = batch.task_ids.length;
+ const shortId = batchId.slice(0, 8);
+ const outcomeMessage =
+ batch.status === 'COMPLETED'
+ ? `Batch ${shortId} finished — ${completedCount}/${total} tasks done`
+ : batch.status === 'FAILED'
+ ? `Batch ${shortId} failed — ${completedCount}/${total} tasks completed before failure`
+ : `Batch ${shortId} cancelled — ${completedCount}/${total} tasks completed`;
+ addNotification({
+ type: 'batch.completed',
+ batchStatus: batch.status as 'COMPLETED' | 'FAILED' | 'CANCELLED',
+ message: outcomeMessage,
+ batchId,
+ });
+ }
+ prevBatchStatusRef.current = batch.status;
+
+ // Per-task: notify on transition to BLOCKED
+ const prevTaskStatuses = prevTaskStatusesRef.current;
+ for (const taskId of batch.task_ids) {
+ const currentStatus = batch.results[taskId];
+ const prevStatus = prevTaskStatuses[taskId];
+ if (currentStatus === 'BLOCKED' && prevStatus && prevStatus !== 'BLOCKED') {
+ const title = tasks[taskId]?.title;
+ addNotification({
+ type: 'blocker.created',
+ message: title
+ ? `Agent is blocked on "${title}" — your input needed`
+ : 'Agent is blocked — your input needed',
+ taskId,
+ });
+ }
+ prevTaskStatuses[taskId] = currentStatus ?? 'READY';
+ }
+ }, [batch, batchId, tasks, addNotification]);
+
// Auto-expand the first IN_PROGRESS task
useEffect(() => {
if (!batch || expandedTaskId) return;
diff --git a/web-ui/src/components/layout/AppSidebar.tsx b/web-ui/src/components/layout/AppSidebar.tsx
index 88c36e18..f1c1bbcd 100644
--- a/web-ui/src/components/layout/AppSidebar.tsx
+++ b/web-ui/src/components/layout/AppSidebar.tsx
@@ -20,6 +20,7 @@ import {
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
import { blockersApi, sessionsApi } from '@/lib/api';
import { CaptureGlitchModal } from '@/components/proof';
+import { NotificationCenter } from './NotificationCenter';
import type { BlockerListResponse, SessionListResponse, ProofRequirement } from '@/types';
interface NavItem {
@@ -146,8 +147,9 @@ export function AppSidebar() {
})}
- {/* Capture Glitch action */}
+ {/* Sidebar footer: notifications + capture glitch */}