Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
18 changes: 11 additions & 7 deletions docs/PRODUCT_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions web-ui/__mocks__/@hugeicons/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,6 @@ module.exports = {
MoneyBag02Icon: createIconMock('MoneyBag02Icon'),
Analytics01Icon: createIconMock('Analytics01Icon'),
ChartLineData01Icon: createIconMock('ChartLineData01Icon'),
// NotificationCenter
Notification02Icon: createIconMock('Notification02Icon'),
};
13 changes: 13 additions & 0 deletions web-ui/__tests__/app/proof/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions web-ui/__tests__/components/layout/AppSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jest.mock('@/components/proof', () => ({
open ? <div data-testid="capture-modal">CaptureGlitchModal</div> : 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: () => <div data-testid="notification-center" />,
}));

// Mock SWR (used for blocker + session badge counts)
const mockSWRData: Record<string, unknown> = {};
jest.mock('swr', () => ({
Expand Down
54 changes: 54 additions & 0 deletions web-ui/src/__tests__/app/execution/permission-request.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ExecutionLandingPage />);
expect(requestPermissionMock).toHaveBeenCalledTimes(1);
});

it('does NOT request permission when already granted', () => {
currentPermission = 'granted';
render(<ExecutionLandingPage />);
expect(requestPermissionMock).not.toHaveBeenCalled();
});

it('does NOT request permission when already denied', () => {
currentPermission = 'denied';
render(<ExecutionLandingPage />);
expect(requestPermissionMock).not.toHaveBeenCalled();
});
});
205 changes: 205 additions & 0 deletions web-ui/src/__tests__/components/layout/NotificationCenter.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <NotificationProvider>{children}</NotificationProvider>;
}

function Adder({ buttonId, payload }: { buttonId: string; payload: { type: 'batch.completed' | 'blocker.created' | 'gate.run.failed'; message: string } }) {
const { addNotification } = useNotificationContext();
return (
<button data-testid={buttonId} onClick={() => addNotification(payload)}>
add
</button>
);
}

beforeEach(() => {
localStorage.clear();
});

describe('NotificationCenter', () => {
it('renders a bell button with no badge when there are no unread notifications', () => {
render(
<Harness>
<NotificationCenter />
</Harness>
);
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(
<Harness>
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'Batch 1 done' }} />
<NotificationCenter />
</Harness>
);
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(
<Harness>
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'Batch 1 done' }} />
<Adder buttonId="add-2" payload={{ type: 'blocker.created', message: 'Blocked: task 7' }} />
<NotificationCenter />
</Harness>
);

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(
<Harness>
<NotificationCenter />
</Harness>
);
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(
<Harness>
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'msg-x' }} />
<NotificationCenter />
</Harness>
);
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(
<Harness>
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'a' }} />
<Adder buttonId="add-2" payload={{ type: 'blocker.created', message: 'b' }} />
<NotificationCenter />
</Harness>
);
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(
<Harness>
<Adder buttonId="add-1" payload={{ type: 'batch.completed', message: 'a' }} />
<NotificationCenter />
</Harness>
);
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(
<Harness>
<NotificationCenter />
</Harness>
);
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(
<Harness>
<NotificationCenter />
</Harness>
);
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(
<Harness>
<NotificationCenter />
</Harness>
);
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(
<Harness>
<NotificationCenter />
</Harness>
);
expect(screen.getByTestId('notification-badge')).toHaveTextContent('1');
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText(/gate failed: unit/)).toBeInTheDocument();
});
});
11 changes: 11 additions & 0 deletions web-ui/src/__tests__/components/proof/ProofPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <span data-testid="status-badge">{status}</span>,
WaiveDialog: () => null,
Expand Down
Loading
Loading