diff --git a/CLAUDE.md b/CLAUDE.md index b808d666..1a04bd22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Issue **import + traceability** (#565) is **complete**: `POST /api/v2/integratio **Phase 5.4 is complete** — PRD stress-test web UI: trigger + streaming (#561). Backend: `GET /api/v2/prd/stress-test` SSE endpoint streams `goals_extracted`, `goal_analyzed`, `complete`, and `error` events from `core/prd_stress_test.py:stress_test_prd_stream()`, resolving the LLM provider via the standard chain and applying the standard rate limit. Frontend: `useStressTestStream` hook manages the SSE connection and event accumulation; `StressTestModal` renders the streaming progress and is opened via a "Stress Test" button on the `/prd` page (enabled only when a PRD exists). Results rendering + refinement (#562) is **complete**: the `complete` SSE event now carries structured, severity-tagged `ambiguities` (`Ambiguity.severity` is `"blocking"`/`"warning"`); `StressTestModal` shows a results view of `AmbiguityCard`s (question text, severity badge, answer textarea) with an "X of Y answered" progress indicator and a **[Refine PRD]** button (disabled until every blocking ambiguity is answered). Refine posts to `POST /api/v2/prd/stress-test/refine`, which folds the answers into a new PRD version via `resolve_ambiguities_into_prd` (offloaded with `asyncio.to_thread`) and `prd.create_new_version`, then `mutatePrd` reflects it in the editor. **Phase 5.3 is complete** — Async notifications cover both surfaces: -- **Browser + in-app center (#559)**: `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). +- **Browser + in-app center (#559)**: `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. `/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`. **Background delivery (#652)**: a cross-page watcher (`useBatchNotificationWatcher`, mounted once in `NotificationProvider` in the root layout so it runs on every route) polls `GET /api/v2/batches` and is the single dispatcher of `batch.completed` (terminal transitions, distinguishing COMPLETED/FAILED/CANCELLED) and `blocker.created` (per-task BLOCKED transitions) — so these fire even when `BatchExecutionMonitor` is unmounted. The watcher baselines on its first poll (no spurious alerts for already-terminal/blocked batches), resets on workspace change, and guards against overlapping in-flight polls; `BatchExecutionMonitor` no longer dispatches them (avoids duplicates). Remaining limitation: `gate.run.failed` stays page-scoped to `/proof` (a proof run is a synchronous request/response the user actively watches, not a server-tracked background job). - **Outbound webhook (#560)**: Settings → Notifications tab takes a single URL + enabled toggle, persisted to `.codeframe/notifications_config.json` via `atomic_write_json`. `GET/PUT /api/v2/settings/notifications` and `POST /api/v2/settings/notifications/test` (test fires a sample payload and surfaces status code). `WebhookNotificationService.send_event` is the generic backend; dispatched fire-and-forget (5s timeout) from `core/conductor.py` on `BATCH_COMPLETED` only (not PARTIAL/FAILED/CANCELLED), `core/blockers.py:create()` after `BLOCKER_CREATED`, and `ui/routers/pr_v2.py:merge_pull_request` after successful merge. Failures are logged but never break the triggering operation. **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`. diff --git a/web-ui/src/__tests__/hooks/useBatchNotificationWatcher.test.ts b/web-ui/src/__tests__/hooks/useBatchNotificationWatcher.test.ts new file mode 100644 index 00000000..5071c35a --- /dev/null +++ b/web-ui/src/__tests__/hooks/useBatchNotificationWatcher.test.ts @@ -0,0 +1,239 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useBatchNotificationWatcher } from '@/hooks/useBatchNotificationWatcher'; +import { batchesApi, tasksApi } from '@/lib/api'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import type { BatchListResponse, BatchResponse, Task } from '@/types'; + +jest.mock('@/lib/api'); +jest.mock('@/lib/workspace-storage'); + +const mockList = batchesApi.list as jest.MockedFunction; +const mockGetTask = tasksApi.getOne as jest.MockedFunction; +const mockGetWorkspacePath = getSelectedWorkspacePath as jest.MockedFunction< + typeof getSelectedWorkspacePath +>; + +function batch(overrides: Partial = {}): BatchResponse { + return { + id: 'batch-1234abcd', + workspace_id: 'ws-1', + task_ids: ['t1'], + status: 'RUNNING', + strategy: 'serial', + max_parallel: 1, + on_failure: 'continue', + started_at: null, + completed_at: null, + results: { t1: 'IN_PROGRESS' }, + ...overrides, + }; +} + +function listResponse(batches: BatchResponse[]): BatchListResponse { + return { batches, total: batches.length, by_status: {} }; +} + +// Queue a sequence of list() responses, one per poll tick. +function queueResponses(...responses: BatchListResponse[]) { + mockList.mockReset(); + responses.forEach((r) => mockList.mockResolvedValueOnce(r)); + // Any further polls repeat the last response. + if (responses.length > 0) { + mockList.mockResolvedValue(responses[responses.length - 1]); + } +} + +const INTERVAL = 1000; + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockGetWorkspacePath.mockReturnValue('/ws'); + mockGetTask.mockResolvedValue({ id: 't1', title: 'Build login form' } as Task); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +/** Run the immediate mount poll + flush its async work. */ +async function flushPoll() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +/** Advance one polling interval and flush async work. */ +async function tick() { + await act(async () => { + jest.advanceTimersByTime(INTERVAL); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useBatchNotificationWatcher', () => { + it('does not notify for batches already terminal on the first poll (baseline)', async () => { + const addNotification = jest.fn(); + queueResponses(listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })])); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); + + expect(addNotification).not.toHaveBeenCalled(); + }); + + it('fires batch.completed when a running batch transitions to a terminal state', async () => { + const addNotification = jest.fn(); + queueResponses( + listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]), + listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })]) + ); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); // baseline = RUNNING + expect(addNotification).not.toHaveBeenCalled(); + + await tick(); // now COMPLETED + + expect(addNotification).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'batch.completed', + batchStatus: 'COMPLETED', + batchId: 'batch-1234abcd', + }) + ); + }); + + it('fires batch.completed only once across repeated polls', async () => { + const addNotification = jest.fn(); + queueResponses( + listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]), + listResponse([batch({ status: 'FAILED', results: { t1: 'FAILED' } })]) + ); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); + await tick(); // FAILED + await tick(); // still FAILED — must not re-fire + + expect(addNotification).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ type: 'batch.completed', batchStatus: 'FAILED' }) + ); + }); + + it('fires blocker.created with the task title when a task transitions to BLOCKED', async () => { + const addNotification = jest.fn(); + queueResponses( + listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]), + listResponse([batch({ status: 'RUNNING', results: { t1: 'BLOCKED' } })]) + ); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); + await tick(); + + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'blocker.created', + taskId: 't1', + message: expect.stringContaining('Build login form'), + }) + ) + ); + }); + + it('does nothing when no workspace is selected', async () => { + const addNotification = jest.fn(); + mockGetWorkspacePath.mockReturnValue(null); + queueResponses(listResponse([batch()])); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); + + expect(mockList).not.toHaveBeenCalled(); + expect(addNotification).not.toHaveBeenCalled(); + }); + + it('does not start an overlapping poll while one is still in flight', async () => { + const addNotification = jest.fn(); + // First list() never resolves during the test window — simulates a slow poll. + let resolveSlow: (v: BatchListResponse) => void = () => {}; + const slow = new Promise((res) => { + resolveSlow = res; + }); + mockList.mockReset(); + mockList.mockReturnValueOnce(slow); + mockList.mockResolvedValue(listResponse([batch()])); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); // immediate poll starts, awaiting `slow` + await tick(); // interval fires but must be skipped (in-flight) + await tick(); + + // Only the one still-pending call was made; no overlap. + expect(mockList).toHaveBeenCalledTimes(1); + + // Let the slow poll finish; subsequent ticks resume normally. + await act(async () => { + resolveSlow(listResponse([batch()])); + await Promise.resolve(); + await Promise.resolve(); + }); + await tick(); + expect(mockList.mock.calls.length).toBeGreaterThan(1); + }); + + it('does not dispatch stale notifications when the workspace changes mid-poll', async () => { + const addNotification = jest.fn(); + // A slow poll for workspace /ws-a that resolves with a terminal transition. + let resolveSlow: (v: BatchListResponse) => void = () => {}; + const slow = new Promise((res) => { + resolveSlow = res; + }); + mockGetWorkspacePath.mockReturnValue('/ws-a'); + mockList.mockReset(); + // First poll (baseline) sees RUNNING; second poll returns the slow promise. + mockList.mockResolvedValueOnce( + listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]) + ); + mockList.mockReturnValueOnce(slow); + mockList.mockResolvedValue(listResponse([batch()])); + + renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })); + await flushPoll(); // baseline RUNNING for /ws-a + await tick(); // second poll starts, awaiting `slow` + + // Workspace switches away before the slow poll resolves. + mockGetWorkspacePath.mockReturnValue('/ws-b'); + await act(async () => { + resolveSlow(listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })])); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The terminal transition belongs to /ws-a, which is no longer active. + expect(addNotification).not.toHaveBeenCalled(); + }); + + it('stops polling after unmount', async () => { + const addNotification = jest.fn(); + queueResponses(listResponse([batch()])); + + const { unmount } = renderHook(() => + useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }) + ); + await flushPoll(); + const callsBefore = mockList.mock.calls.length; + + unmount(); + await tick(); + + expect(mockList.mock.calls.length).toBe(callsBefore); + }); +}); diff --git a/web-ui/src/components/execution/BatchExecutionMonitor.tsx b/web-ui/src/components/execution/BatchExecutionMonitor.tsx index d758de1f..f5ca025b 100644 --- a/web-ui/src/components/execution/BatchExecutionMonitor.tsx +++ b/web-ui/src/components/execution/BatchExecutionMonitor.tsx @@ -24,7 +24,6 @@ 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 ──────────────────────────────────────────────── @@ -51,7 +50,6 @@ 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); @@ -61,10 +59,6 @@ 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 { @@ -104,55 +98,10 @@ 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]); + // Note: batch.completed / blocker.created notifications are dispatched by the + // cross-page background watcher in NotificationProvider (issue #652), so they + // fire even when this monitor is unmounted. This component only renders the + // live view; it no longer dispatches notifications to avoid duplicates. // Auto-expand the first IN_PROGRESS task useEffect(() => { diff --git a/web-ui/src/contexts/NotificationContext.tsx b/web-ui/src/contexts/NotificationContext.tsx index 246b572e..0c38f50e 100644 --- a/web-ui/src/contexts/NotificationContext.tsx +++ b/web-ui/src/contexts/NotificationContext.tsx @@ -2,13 +2,28 @@ import { createContext, useContext, type ReactNode } from 'react'; import { useNotifications, type UseNotificationsReturn } from '@/hooks/useNotifications'; +import { useBatchNotificationWatcher } from '@/hooks/useBatchNotificationWatcher'; const NotificationContext = createContext(null); +/** + * Cross-page background watcher. Mounted once here (inside the provider, so it + * runs on every route) it is the single dispatcher of batch.completed and + * blocker.created — making those notifications fire even when the execution + * page is unmounted. See issue #652. + */ +function BackgroundBatchWatcher({ addNotification }: { addNotification: UseNotificationsReturn['addNotification'] }) { + useBatchNotificationWatcher(addNotification); + return null; +} + export function NotificationProvider({ children }: { children: ReactNode }) { const value = useNotifications(); return ( - {children} + + + {children} + ); } diff --git a/web-ui/src/hooks/useBatchNotificationWatcher.ts b/web-ui/src/hooks/useBatchNotificationWatcher.ts new file mode 100644 index 00000000..14891d24 --- /dev/null +++ b/web-ui/src/hooks/useBatchNotificationWatcher.ts @@ -0,0 +1,176 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { batchesApi, tasksApi } from '@/lib/api'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import type { AddNotificationInput } from '@/hooks/useNotifications'; +import type { AppNotificationBatchStatus } from '@/types'; + +const DEFAULT_INTERVAL_MS = 10_000; +const TERMINAL = ['COMPLETED', 'FAILED', 'CANCELLED']; + +interface UseBatchNotificationWatcherOptions { + intervalMs?: number; +} + +/** + * Cross-page background watcher that polls the workspace's batches and fires + * notifications on terminal/blocked transitions — regardless of the current + * route. This is what makes batch notifications work when the execution page + * (and its `BatchExecutionMonitor`) is unmounted. See issue #652. + * + * Mounted once inside `NotificationProvider`, it is the single source of truth + * for `batch.completed` and `blocker.created`; `BatchExecutionMonitor` no + * longer dispatches these to avoid duplicates. + */ +export function useBatchNotificationWatcher( + addNotification: (input: AddNotificationInput) => void, + options?: UseBatchNotificationWatcherOptions +): void { + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; + + // Latest addNotification without retriggering the polling effect. + const addRef = useRef(addNotification); + addRef.current = addNotification; + + // Per-session transition baselines. `undefined` for a batch/task means + // "never seen" — we record it without notifying so pre-existing terminal or + // blocked state doesn't produce a spurious alert on first poll. + const prevBatchStatusRef = useRef>({}); + const prevTaskStatusRef = useRef>({}); + // Workspace whose baselines the refs currently hold; resets on change. + const watchedWorkspaceRef = useRef(null); + // Guards against overlapping polls: a slow poll (e.g. a sluggish list or the + // extra title fetch on a BLOCKED transition) must not run concurrently with + // the next tick, or two runs would read the same baseline before either + // writes it — double-firing or re-arming a transition. + const pollingRef = useRef(false); + + useEffect(() => { + let cancelled = false; + + const poll = async () => { + if (pollingRef.current) return; + const workspacePath = getSelectedWorkspacePath(); + if (!workspacePath) return; + pollingRef.current = true; + try { + await runPoll(workspacePath); + } finally { + pollingRef.current = false; + } + }; + + const runPoll = async (workspacePath: string) => { + // Reset baselines when the active workspace changes so we don't compare + // statuses across unrelated batch sets. + if (watchedWorkspaceRef.current !== workspacePath) { + watchedWorkspaceRef.current = workspacePath; + prevBatchStatusRef.current = {}; + prevTaskStatusRef.current = {}; + } + + // The workspace can change while the request is in flight; a late- + // resolving poll for the previous workspace must not dispatch into the + // new one's notification store. Also covers unmount via `cancelled`. + const isCurrentWorkspace = () => + !cancelled && getSelectedWorkspacePath() === workspacePath; + + let batches; + try { + const response = await batchesApi.list(workspacePath, { limit: 50 }); + batches = response.batches; + } catch { + // Transient API/network failure — keep baselines and retry next tick. + return; + } + if (!isCurrentWorkspace()) return; + + const prevBatchStatus = prevBatchStatusRef.current; + const prevTaskStatus = prevTaskStatusRef.current; + + for (const batch of batches) { + // ── Batch terminal transition ────────────────────────────────── + const prevStatus = prevBatchStatus[batch.id]; + if ( + prevStatus !== undefined && + !TERMINAL.includes(prevStatus) && + TERMINAL.includes(batch.status) + ) { + addRef.current({ + type: 'batch.completed', + batchStatus: batch.status as AppNotificationBatchStatus, + message: buildBatchMessage(batch.id, batch.status, batch.task_ids, batch.results), + batchId: batch.id, + }); + } + prevBatchStatus[batch.id] = batch.status; + + // ── Per-task BLOCKED transition ──────────────────────────────── + for (const taskId of batch.task_ids) { + const current = batch.results[taskId] ?? 'READY'; + const prev = prevTaskStatus[taskId]; + if (prev !== undefined && current === 'BLOCKED' && prev !== 'BLOCKED') { + void notifyBlocked(workspacePath, taskId, addRef.current, isCurrentWorkspace); + } + prevTaskStatus[taskId] = current; + } + } + }; + + // Poll immediately so a freshly-finished batch is reported without waiting + // a full interval, then on a steady cadence. + void poll(); + const timer = setInterval(poll, intervalMs); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [intervalMs]); +} + +function buildBatchMessage( + batchId: string, + status: string, + taskIds: string[], + results: Record +): string { + const completedCount = taskIds.filter( + (id) => results[id] === 'COMPLETED' || results[id] === 'DONE' + ).length; + const total = taskIds.length; + const shortId = batchId.slice(0, 8); + if (status === 'COMPLETED') { + return `Batch ${shortId} finished — ${completedCount}/${total} tasks done`; + } + if (status === 'FAILED') { + return `Batch ${shortId} failed — ${completedCount}/${total} tasks completed before failure`; + } + return `Batch ${shortId} cancelled — ${completedCount}/${total} tasks completed`; +} + +async function notifyBlocked( + workspacePath: string, + taskId: string, + add: (input: AddNotificationInput) => void, + isCurrentWorkspace: () => boolean +): Promise { + if (!isCurrentWorkspace()) return; + let title: string | undefined; + try { + const task = await tasksApi.getOne(workspacePath, taskId); + title = task.title; + } catch { + // Title is best-effort; fall back to a generic message. + } + // The title fetch is itself async — re-check before dispatching. + if (!isCurrentWorkspace()) return; + add({ + type: 'blocker.created', + message: title + ? `Agent is blocked on "${title}" — your input needed` + : 'Agent is blocked — your input needed', + taskId, + }); +} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index dc410cb8..ceb322c4 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -31,6 +31,7 @@ import type { BlockerStatus, BlockerListResponse, BatchResponse, + BatchListResponse, DiffStatsResponse, PatchResponse, CommitMessageResponse, @@ -487,6 +488,25 @@ export const blockersApi = { // Batches API methods export const batchesApi = { + /** + * List batches in the workspace, optionally filtered by status. + * Returns each batch with per-task results — used by the background + * notification watcher to detect terminal/blocked transitions. + */ + list: async ( + workspacePath: string, + options?: { status?: string; limit?: number } + ): Promise => { + const response = await api.get('/api/v2/batches', { + params: { + workspace_path: workspacePath, + ...(options?.status ? { status: options.status } : {}), + ...(options?.limit ? { limit: options.limit } : {}), + }, + }); + return response.data; + }, + /** * Get batch details including per-task results */ diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 5c21764d..ea6f9d43 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -161,6 +161,13 @@ export interface BatchResponse { results: Record; // task_id → RunStatus } +// Mirrors batches_v2.py:BatchListResponse +export interface BatchListResponse { + batches: BatchResponse[]; + total: number; + by_status: Record; +} + // UI-derived agent state for execution monitor display export type UIAgentState = | 'CONNECTING'