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 */}
+ + + {open && ( + + )} +
+ ); +} + +function NotificationRow({ + notification, + onMarkRead, +}: { + notification: AppNotification; + onMarkRead: (id: string) => void; +}) { + const { Icon, color: colorClass } = iconForNotification(notification); + return ( +
  • +
  • + ); +} diff --git a/web-ui/src/contexts/NotificationContext.tsx b/web-ui/src/contexts/NotificationContext.tsx new file mode 100644 index 00000000..246b572e --- /dev/null +++ b/web-ui/src/contexts/NotificationContext.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; +import { useNotifications, type UseNotificationsReturn } from '@/hooks/useNotifications'; + +const NotificationContext = createContext(null); + +export function NotificationProvider({ children }: { children: ReactNode }) { + const value = useNotifications(); + return ( + {children} + ); +} + +export function useNotificationContext(): UseNotificationsReturn { + const ctx = useContext(NotificationContext); + if (!ctx) { + throw new Error('useNotificationContext must be used inside '); + } + return ctx; +} diff --git a/web-ui/src/hooks/useNotifications.ts b/web-ui/src/hooks/useNotifications.ts new file mode 100644 index 00000000..01c606d1 --- /dev/null +++ b/web-ui/src/hooks/useNotifications.ts @@ -0,0 +1,185 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import type { AppNotification, AppNotificationBatchStatus, AppNotificationType } from '@/types'; + +export const NOTIFICATIONS_STORAGE_KEY_PREFIX = 'codeframe_notifications'; +export const NOTIFICATIONS_GLOBAL_STORAGE_KEY = NOTIFICATIONS_STORAGE_KEY_PREFIX; +export const MAX_NOTIFICATIONS = 20; + +// Back-compat alias for tests written against the original key name. +export const NOTIFICATIONS_STORAGE_KEY = NOTIFICATIONS_GLOBAL_STORAGE_KEY; + +const TITLES: Record = { + 'batch.completed': 'Batch finished', + 'blocker.created': 'Blocker needs input', + 'gate.run.failed': 'Gate run failed', +}; + +function storageKeyFor(workspacePath: string | null): string { + if (!workspacePath) return NOTIFICATIONS_GLOBAL_STORAGE_KEY; + return `${NOTIFICATIONS_STORAGE_KEY_PREFIX}_${encodeURIComponent(workspacePath)}`; +} + +export interface AddNotificationInput { + type: AppNotificationType; + message: string; + batchId?: string; + batchStatus?: AppNotificationBatchStatus; + taskId?: string; + gateName?: string; +} + +export interface UseNotificationsReturn { + notifications: AppNotification[]; + unreadCount: number; + addNotification: (input: AddNotificationInput) => void; + markRead: (id: string) => void; + markAllRead: () => void; + clearAll: () => void; +} + +function readStored(key: string): AppNotification[] { + if (typeof window === 'undefined') return []; + const raw = localStorage.getItem(key); + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as AppNotification[]; + return Array.isArray(parsed) ? parsed.slice(0, MAX_NOTIFICATIONS) : []; + } catch { + return []; + } +} + +function persist(key: string, notifications: AppNotification[]): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(key, JSON.stringify(notifications)); + } catch (err) { + // Quota exceeded, private-mode restriction, or storage disabled: drop the + // write rather than crash the caller. In-memory state still reflects the + // update for the rest of the session. + console.error('[useNotifications] failed to persist notifications:', err); + } +} + +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function maybeFireBrowserNotification(n: AppNotification): void { + if (typeof window === 'undefined') return; + if (typeof Notification === 'undefined') return; + if (document.visibilityState === 'visible') return; + if (Notification.permission !== 'granted') return; + try { + new Notification(TITLES[n.type], { + body: n.message, + icon: '/favicon.ico', + tag: n.id, + }); + } catch { + // Some browsers throw if Notification is invoked from an insecure context; + // ignore — the in-app history still records the event. + } +} + +export function useNotifications(): UseNotificationsReturn { + const [workspacePath, setWorkspacePath] = useState(null); + const [notifications, setNotifications] = useState([]); + + // Hydrate from localStorage when the workspace changes + useEffect(() => { + if (typeof window === 'undefined') return; + const path = getSelectedWorkspacePath(); + setWorkspacePath(path); + setNotifications(readStored(storageKeyFor(path))); + + const reload = () => { + const next = getSelectedWorkspacePath(); + setWorkspacePath(next); + setNotifications(readStored(storageKeyFor(next))); + }; + // Cross-tab storage events fire for every localStorage key. Filter so we + // only react to (a) the active-workspace key, or (b) keys belonging to + // the notification store. Unrelated mutations are ignored. + const onStorage = (event: StorageEvent) => { + const key = event.key; + if (key === null) { + reload(); // localStorage.clear() — refresh defensively + return; + } + if (key === 'codeframe_workspace_path') { + reload(); + return; + } + if (key.startsWith(NOTIFICATIONS_STORAGE_KEY_PREFIX)) { + reload(); + } + }; + window.addEventListener('storage', onStorage); + window.addEventListener('workspaceChanged', reload); + return () => { + window.removeEventListener('storage', onStorage); + window.removeEventListener('workspaceChanged', reload); + }; + }, []); + + const addNotification = useCallback( + (input: AddNotificationInput) => { + setNotifications((prev) => { + const entry: AppNotification = { + id: generateId(), + type: input.type, + message: input.message, + timestamp: new Date().toISOString(), + read: false, + batchId: input.batchId, + batchStatus: input.batchStatus, + taskId: input.taskId, + gateName: input.gateName, + workspacePath: workspacePath ?? undefined, + }; + const next = [entry, ...prev].slice(0, MAX_NOTIFICATIONS); + persist(storageKeyFor(workspacePath), next); + maybeFireBrowserNotification(entry); + return next; + }); + }, + [workspacePath] + ); + + const markRead = useCallback( + (id: string) => { + setNotifications((prev) => { + const next = prev.map((n) => (n.id === id ? { ...n, read: true } : n)); + persist(storageKeyFor(workspacePath), next); + return next; + }); + }, + [workspacePath] + ); + + const markAllRead = useCallback(() => { + setNotifications((prev) => { + const next = prev.map((n) => ({ ...n, read: true })); + persist(storageKeyFor(workspacePath), next); + return next; + }); + }, [workspacePath]); + + const clearAll = useCallback(() => { + setNotifications(() => { + persist(storageKeyFor(workspacePath), []); + return []; + }); + }, [workspacePath]); + + const unreadCount = notifications.reduce((acc, n) => acc + (n.read ? 0 : 1), 0); + + return { notifications, unreadCount, addNotification, markRead, markAllRead, clearAll }; +} diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 3a3e3589..bf43542d 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -660,3 +660,26 @@ export interface AgentCostsResponse { total_input_tokens: number; total_output_tokens: number; } + +// Async notifications (issue #559) +export type AppNotificationType = + | 'batch.completed' + | 'blocker.created' + | 'gate.run.failed'; + +export type AppNotificationBatchStatus = 'COMPLETED' | 'FAILED' | 'CANCELLED'; + +export interface AppNotification { + id: string; + type: AppNotificationType; + message: string; + timestamp: string; + read: boolean; + batchId?: string; + /** Final batch status — set when type is 'batch.completed'. */ + batchStatus?: AppNotificationBatchStatus; + taskId?: string; + gateName?: string; + /** Workspace this notification belongs to. Populated automatically by the hook. */ + workspacePath?: string; +}