diff --git a/codeframe/ui/routers/streaming_v2.py b/codeframe/ui/routers/streaming_v2.py index 3116592d..06ee14c6 100644 --- a/codeframe/ui/routers/streaming_v2.py +++ b/codeframe/ui/routers/streaming_v2.py @@ -1,32 +1,21 @@ -"""SSE streaming router for real-time task execution events. +"""SSE streaming utilities for real-time task execution events. -This module provides Server-Sent Events (SSE) endpoints for streaming -task execution progress to web clients. +This module provides shared SSE utilities (formatting, event generation, +publisher management) used by streaming consumers. -Endpoints: -- GET /api/v2/tasks/{task_id}/stream - SSE stream of execution events - -This router follows the thin adapter pattern: -1. Parse HTTP request parameters -2. Subscribe to EventPublisher from core.streaming -3. Format events as SSE and stream to client -4. Handle disconnection gracefully +The actual SSE endpoint for tasks is in tasks_v2.py: + GET /api/v2/tasks/{task_id}/stream (requires workspace_path only) """ import asyncio import logging from typing import AsyncGenerator, Optional -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse # noqa: F401 — re-exported -from codeframe.auth import User -from codeframe.auth.dependencies import get_current_user -from codeframe.core import tasks from codeframe.core.models import ExecutionEvent from codeframe.core.streaming import EventPublisher -from codeframe.core.workspace import Workspace -from codeframe.ui.dependencies import get_v2_workspace logger = logging.getLogger(__name__) @@ -96,37 +85,53 @@ async def event_stream_generator( task_id: str, publisher: EventPublisher, request: Request, - heartbeat_interval: float = 30.0, + heartbeat_interval: float = 15.0, ) -> AsyncGenerator[str, None]: - """Generate SSE events for a task. + """Generate SSE events for a task with heartbeat keep-alive. - This async generator yields SSE-formatted strings as events - are published for the given task. + Subscribes to the EventPublisher and yields SSE-formatted strings. + Emits SSE comments as heartbeats during idle periods to prevent + proxy/browser timeouts. Args: task_id: Task ID to stream events for publisher: EventPublisher to subscribe to request: FastAPI request (for disconnect detection) - heartbeat_interval: Seconds between heartbeat events + heartbeat_interval: Seconds between heartbeat comments Yields: - SSE-formatted event strings + SSE-formatted event strings or comment heartbeats """ logger.info(f"Starting SSE stream for task {task_id}") + queue: asyncio.Queue = asyncio.Queue(maxsize=1000) + loop = asyncio.get_running_loop() + + from codeframe.core.streaming import _Subscription + subscription = _Subscription(task_id, queue, loop) + + async with publisher._lock: + publisher._subscribers[task_id].append(subscription) + try: - async for event in publisher.subscribe(task_id): - # Check if client disconnected + while True: if await request.is_disconnected(): logger.info(f"Client disconnected from task {task_id} stream") break - yield format_sse_event(event) + try: + item = await asyncio.wait_for(queue.get(), timeout=heartbeat_interval) - # If this is a completion event, we're done - if event.event_type == "completion": - logger.info(f"Task {task_id} completed, closing stream") - break + if item is _Subscription.END_OF_STREAM: + break + + yield format_sse_event(item) + + if item.event_type == "completion": + logger.info(f"Task {task_id} completed, closing stream") + break + except asyncio.TimeoutError: + yield format_sse_comment("heartbeat") except asyncio.CancelledError: logger.info(f"SSE stream cancelled for task {task_id}") @@ -135,97 +140,16 @@ async def event_stream_generator( logger.error(f"Error in SSE stream for task {task_id}: {e}") raise finally: + async with publisher._lock: + if subscription in publisher._subscribers[task_id]: + publisher._subscribers[task_id].remove(subscription) + if not publisher._subscribers[task_id]: + del publisher._subscribers[task_id] logger.info(f"Closing SSE stream for task {task_id}") -@router.get( - "/{task_id}/stream", - response_class=StreamingResponse, - summary="Stream task execution events", - description=""" - Stream real-time execution events for a task using Server-Sent Events (SSE). - - **Authentication required**: Pass JWT token via Authorization header or cookie. - - The stream includes: - - **progress**: Phase transitions and step updates - - **output**: stdout/stderr from commands - - **blocker**: Human-in-the-loop questions - - **completion**: Task finished (stream closes) - - **error**: Errors during execution - - **heartbeat**: Keep-alive (configurable, default 30s) - - The stream closes when: - - Task completes (success or failure) - - Client disconnects - - Server error occurs - - Example client (JavaScript): - ```javascript - const eventSource = new EventSource('/api/v2/tasks/123/stream', { - headers: { 'Authorization': 'Bearer ' } - }); - eventSource.onmessage = (e) => { - const event = JSON.parse(e.data); - console.log(event.event_type, event.data); - }; - ``` - - Configuration (via environment variables): - - SSE_TIMEOUT_SECONDS: Timeout for event wait (default: 30) - - SSE_MAX_QUEUE_SIZE: Max queued events (default: 1000) - - SSE_OUTPUT_MAX_CHARS: Max output chars per event (default: 2000) - """, - responses={ - 200: { - "description": "SSE event stream", - "content": { - "text/event-stream": { - "example": 'data: {"event_type":"progress","task_id":"123",...}\n\n' - } - }, - }, - 401: {"description": "Authentication required"}, - 404: {"description": "Task not found"}, - }, -) -async def stream_task_events( - task_id: str, - request: Request, - workspace: Workspace = Depends(get_v2_workspace), - current_user: User = Depends(get_current_user), -) -> StreamingResponse: - """Stream execution events for a task via SSE. - - Args: - task_id: ID of the task to stream - request: FastAPI request object - workspace: User's workspace (injected by dependency) - current_user: Authenticated user (injected by dependency) - - Returns: - StreamingResponse with SSE content type - - Raises: - HTTPException: 404 if task not found in workspace - """ - # Verify task exists in user's workspace - task = tasks.get(workspace, task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - publisher = get_event_publisher() - - # Log subscription without PII (use user ID instead of email) - logger.info("User %s subscribed to task %s stream in workspace %s", - current_user.id, task_id, workspace.id) - - return StreamingResponse( - event_stream_generator(task_id, publisher, request), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Disable nginx buffering - }, - ) +# NOTE: The SSE stream endpoint for tasks is defined in tasks_v2.py +# (GET /api/v2/tasks/{task_id}/stream) which only requires workspace_path +# and is compatible with browser EventSource (no custom auth headers needed). +# This module retains the shared utilities (format_sse_event, format_sse_comment, +# event_stream_generator, get_event_publisher) used by other streaming consumers. diff --git a/codeframe/ui/routers/tasks_v2.py b/codeframe/ui/routers/tasks_v2.py index 09bde469..6e660880 100644 --- a/codeframe/ui/routers/tasks_v2.py +++ b/codeframe/ui/routers/tasks_v2.py @@ -15,6 +15,7 @@ """ import logging +import threading from typing import Any, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request @@ -24,6 +25,7 @@ from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard from codeframe.core import runtime, tasks, conductor, streaming +from codeframe.core.runtime import RunStatus from codeframe.core.state_machine import TaskStatus from codeframe.ui.dependencies import get_v2_workspace from codeframe.ui.response_models import api_error, ErrorCodes @@ -595,15 +597,37 @@ async def start_single_task( } if execute: - # Execute agent synchronously (for API, might want to make this async/background) - state = runtime.execute_agent( - workspace, - run, - dry_run=dry_run, - verbose=verbose, - ) - result["agent_status"] = state.status.value if hasattr(state.status, 'value') else str(state.status) - result["message"] = f"Execution completed with status: {result['agent_status']}" + from codeframe.ui.routers.streaming_v2 import get_event_publisher + from codeframe.core.models import ErrorEvent + + publisher = get_event_publisher() + + def _run_agent(): + try: + runtime.execute_agent( + workspace, + run, + dry_run=dry_run, + verbose=verbose, + event_publisher=publisher, + ) + except Exception as exc: + logger.error(f"Background agent failed for task {task_id}: {exc}", exc_info=True) + publisher.publish_sync( + task_id, + ErrorEvent( + task_id=task_id, + error=str(exc), + error_type=type(exc).__name__, + ), + ) + publisher.complete_task_sync(task_id) + + thread = threading.Thread(target=_run_agent, daemon=True) + thread.start() + + result["status"] = "executing" + result["message"] = f"Execution started in background for task {task_id[:8]}. Connect to GET /{task_id}/stream for events." return result @@ -723,19 +747,21 @@ async def resume_task( # ============================================================================ -@router.get("/{task_id}/stream") +@router.get("/{task_id}/output") @rate_limit_standard() -async def stream_task_output( +async def stream_task_output_lines( request: Request, task_id: str, tail: int = Query(0, ge=0, le=1000, description="Show last N lines before streaming"), workspace: Workspace = Depends(get_v2_workspace), ) -> StreamingResponse: - """Stream real-time output from a running task. + """Stream raw output lines from a running task. - Returns Server-Sent Events (SSE) with task output lines. + Returns Server-Sent Events (SSE) with raw text output lines. + This is the API equivalent of `cf work follow `. - This is the v2 equivalent of `cf work follow `. + For structured JSON execution events (progress, output, blocker, + completion, error), use GET /{task_id}/stream instead. Event types: - `line`: A line of output from the task @@ -827,6 +853,101 @@ def generate_events(): ) +@router.get("/{task_id}/stream") +async def stream_task_events( + request: Request, + task_id: str, + workspace: Workspace = Depends(get_v2_workspace), +) -> StreamingResponse: + """Stream structured execution events for a task via SSE. + + Returns Server-Sent Events with JSON-formatted ExecutionEvent payloads. + Compatible with browser EventSource (no custom auth headers required). + + Event types (in data.event_type): + - ``progress``: Phase/step transitions + - ``output``: stdout/stderr lines + - ``blocker``: Human input needed + - ``completion``: Task finished (success/failure/blocked) + - ``error``: Execution error + - ``heartbeat``: Keep-alive + + For raw text output lines (cf work follow equivalent), + use GET /{task_id}/output instead. + """ + task = tasks.get(workspace, task_id) + if not task: + raise HTTPException( + status_code=404, + detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"), + ) + + from codeframe.ui.routers.streaming_v2 import ( + event_stream_generator, + format_sse_event, + get_event_publisher, + ) + from codeframe.core.models import CompletionEvent, ProgressEvent + + publisher = get_event_publisher() + + # Check if the task already has a terminal run — if so, send a synthetic + # completion event immediately instead of waiting for events that will + # never arrive (the agent is done, the EventPublisher has no buffering). + run = runtime.get_active_run(workspace, task_id) + latest_run = runtime.get_latest_run(workspace, task_id) if not run else None + already_terminal = ( + latest_run is not None + and latest_run.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.BLOCKED) + ) if not run else False + + async def _generate(): + # Always send an initial progress event so the browser's EventSource + # fires onmessage and the client transitions from CONNECTING state. + yield format_sse_event( + ProgressEvent( + task_id=task_id, + phase="connected", + step=0, + total_steps=0, + message="Stream connected", + ) + ) + + if already_terminal: + # Task already finished — emit a synthetic completion event. + status_map = { + RunStatus.COMPLETED: "completed", + RunStatus.FAILED: "failed", + RunStatus.BLOCKED: "blocked", + } + duration = 0.0 + if latest_run.started_at and latest_run.completed_at: + duration = (latest_run.completed_at - latest_run.started_at).total_seconds() + yield format_sse_event( + CompletionEvent( + task_id=task_id, + status=status_map[latest_run.status], + duration_seconds=duration, + ) + ) + return + + # Live stream — subscribe to the EventPublisher for real-time events. + async for chunk in event_stream_generator(task_id, publisher, request): + yield chunk + + return StreamingResponse( + _generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @router.get("/{task_id}/run") @rate_limit_standard() async def get_task_run( diff --git a/tests/ui/test_streaming_router.py b/tests/ui/test_streaming_router.py index c0284645..2765fa18 100644 --- a/tests/ui/test_streaming_router.py +++ b/tests/ui/test_streaming_router.py @@ -1,15 +1,11 @@ """Tests for SSE streaming router. -TDD: Tests written first to define expected behavior of -the /api/v2/tasks/{task_id}/stream SSE endpoint. +Tests for SSE event formatting, publisher management, and +the event_stream_generator used by /api/v2/tasks/{task_id}/stream. """ import json -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - from codeframe.core.models import ( ProgressEvent, OutputEvent, @@ -18,57 +14,6 @@ ) -@pytest.fixture -def mock_user(): - """Create a mock authenticated user.""" - from codeframe.auth import User - return User(id=1, email="test@example.com", hashed_password="!DISABLED!") - - -@pytest.fixture -def mock_workspace(tmp_path): - """Create a mock workspace.""" - from codeframe.core.workspace import Workspace - from datetime import datetime, timezone - - workspace = Workspace( - id="test-workspace-id", - repo_path=tmp_path, - state_dir=tmp_path / ".codeframe", - created_at=datetime.now(timezone.utc), - ) - # Create state directory - workspace.state_dir.mkdir(parents=True, exist_ok=True) - return workspace - - -@pytest.fixture -def app_with_streaming(mock_user, mock_workspace, monkeypatch): - """Create a FastAPI app with the streaming router and mocked dependencies.""" - from codeframe.ui.routers.streaming_v2 import router - from codeframe.auth.dependencies import get_current_user - from codeframe.ui.dependencies import get_v2_workspace - from codeframe.core import tasks - - # Create a mock task - from unittest.mock import MagicMock - mock_task = MagicMock() - mock_task.id = "test-task" - mock_task.title = "Test Task" - - # Patch tasks.get to return our mock task - monkeypatch.setattr(tasks, "get", lambda workspace, task_id: mock_task) - - app = FastAPI() - app.include_router(router) - - # Override dependencies - app.dependency_overrides[get_current_user] = lambda: mock_user - app.dependency_overrides[get_v2_workspace] = lambda: mock_workspace - - return app - - class TestSSEEventFormat: """Tests for SSE event formatting.""" @@ -160,66 +105,19 @@ def test_sse_comment_format(self): class TestStreamingRouterEndpoint: - """Tests for streaming router endpoint configuration.""" - - def test_endpoint_exists(self, app_with_streaming): - """The streaming endpoint should be registered.""" - client = TestClient(app_with_streaming) + """Tests for streaming router configuration. - # Get the OpenAPI schema to verify endpoint exists - response = client.get("/openapi.json") - assert response.status_code == 200 + NOTE: The SSE stream endpoint (GET /api/v2/tasks/{task_id}/stream) lives + in tasks_v2.py. It only requires workspace_path, making it compatible + with browser EventSource which cannot send custom auth headers. + streaming_v2.py provides shared utilities only (no endpoints). + """ - schema = response.json() - paths = schema.get("paths", {}) + def test_streaming_router_has_no_endpoints(self): + """streaming_v2 router should have no endpoints (utilities only).""" + from codeframe.ui.routers.streaming_v2 import router - # Verify the stream endpoint is registered - assert "/api/v2/tasks/{task_id}/stream" in paths - assert "get" in paths["/api/v2/tasks/{task_id}/stream"] - - def test_endpoint_returns_streaming_response(self, app_with_streaming): - """The endpoint should return a streaming response with SSE content type.""" - from codeframe.core.streaming import EventPublisher - from codeframe.ui.routers import streaming_v2 - - # Inject a publisher that immediately completes the task - publisher = EventPublisher() - streaming_v2.set_event_publisher(publisher) - - try: - client = TestClient(app_with_streaming) - - # Use stream=True but set a short timeout via the client - # and complete the task immediately - import threading - import time - - def complete_task(): - time.sleep(0.1) - import asyncio - loop = asyncio.new_event_loop() - loop.run_until_complete(publisher.complete_task("test-task")) - loop.close() - - thread = threading.Thread(target=complete_task) - thread.start() - - # The TestClient doesn't easily support streaming, - # so we just verify the endpoint starts without error - # Real streaming tests require async client - with client.stream("GET", "/api/v2/tasks/test-task/stream") as response: - assert response.status_code == 200 - assert "text/event-stream" in response.headers.get("content-type", "") - # Read at most a small amount before breaking - break_after = 0 - for _ in response.iter_lines(): - break_after += 1 - if break_after > 0: - break - - thread.join(timeout=2.0) - finally: - streaming_v2.set_event_publisher(None) + assert len(router.routes) == 0 class TestEventPublisherGlobal: diff --git a/tests/ui/test_v2_routers_integration.py b/tests/ui/test_v2_routers_integration.py index 9601dcbf..1ddd055c 100644 --- a/tests/ui/test_v2_routers_integration.py +++ b/tests/ui/test_v2_routers_integration.py @@ -746,10 +746,10 @@ def test_get_task_run_no_run(self, test_client_with_task): class TestTasksV2Streaming: - """Tests for task streaming endpoint.""" + """Tests for task streaming endpoints (output lines and structured events).""" - def test_stream_task_no_run(self, test_client_with_task): - """Stream endpoint returns 404 when no run exists.""" + def test_output_stream_no_run(self, test_client_with_task): + """Output stream endpoint returns 404 when no run exists.""" from codeframe.core import tasks from codeframe.core.state_machine import TaskStatus @@ -762,12 +762,18 @@ def test_stream_task_no_run(self, test_client_with_task): priority=1, ) - response = test_client_with_task.get(f"/api/v2/tasks/{new_task.id}/stream") + response = test_client_with_task.get(f"/api/v2/tasks/{new_task.id}/output") assert response.status_code == 404 - def test_stream_task_not_found(self, test_client): - """Stream endpoint returns 404 for non-existent task.""" + def test_output_stream_not_found(self, test_client): + """Output stream endpoint returns 404 for non-existent task.""" + response = test_client.get("/api/v2/tasks/nonexistent-id/output") + + assert response.status_code == 404 + + def test_event_stream_not_found(self, test_client): + """Structured event stream returns 404 for non-existent task.""" response = test_client.get("/api/v2/tasks/nonexistent-id/stream") assert response.status_code == 404 diff --git a/web-ui/.env.example b/web-ui/.env.example index de34ed34..65524a5c 100644 --- a/web-ui/.env.example +++ b/web-ui/.env.example @@ -1,3 +1,9 @@ # API URL for the CodeFRAME backend # During development, the Next.js dev server proxies API requests NEXT_PUBLIC_API_URL= + +# SSE (Server-Sent Events) direct URL to the FastAPI backend. +# SSE requires a direct connection — the Next.js rewrite proxy buffers +# chunked responses, which breaks real-time streaming. +# Defaults to http://localhost:8000 if not set. +NEXT_PUBLIC_SSE_URL=http://localhost:8000 diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 8545d714..848216f0 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -42,4 +42,13 @@ module.exports = { LinkCircleIcon: createIconMock('LinkCircleIcon'), Search01Icon: createIconMock('Search01Icon'), CheckListIcon: createIconMock('CheckListIcon'), + // Execution Monitor components + Idea01Icon: createIconMock('Idea01Icon'), + ArrowTurnBackwardIcon: createIconMock('ArrowTurnBackwardIcon'), + CommandLineIcon: createIconMock('CommandLineIcon'), + AlertDiamondIcon: createIconMock('AlertDiamondIcon'), + WifiDisconnected01Icon: createIconMock('WifiDisconnected01Icon'), + SidebarLeftIcon: createIconMock('SidebarLeftIcon'), + ArrowDown01Icon: createIconMock('ArrowDown01Icon'), + StopIcon: createIconMock('StopIcon'), }; diff --git a/web-ui/__tests__/components/execution/BlockerEvent.test.tsx b/web-ui/__tests__/components/execution/BlockerEvent.test.tsx new file mode 100644 index 00000000..f5225958 --- /dev/null +++ b/web-ui/__tests__/components/execution/BlockerEvent.test.tsx @@ -0,0 +1,172 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BlockerEvent } from '@/components/execution/BlockerEvent'; +import type { BlockerEvent as BlockerEventType } from '@/hooks/useTaskStream'; + +// ── Mock API ────────────────────────────────────────────────────────── + +const mockAnswer = jest.fn(); + +jest.mock('@/lib/api', () => ({ + blockersApi: { + answer: (...args: unknown[]) => mockAnswer(...args), + }, +})); + +// ── Fixtures ────────────────────────────────────────────────────────── + +function makeBlockerEvent(overrides: Partial = {}): BlockerEventType { + return { + event_type: 'blocker', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + blocker_id: 42, + question: 'Which database should be used?', + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockAnswer.mockResolvedValue({}); +}); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('BlockerEvent', () => { + it('renders the blocker question and header', () => { + render( + + ); + + expect(screen.getByText('Agent needs your help')).toBeInTheDocument(); + expect(screen.getByText('Which database should be used?')).toBeInTheDocument(); + expect(screen.getByText('Execution paused — waiting for response...')).toBeInTheDocument(); + }); + + it('renders context when provided', () => { + render( + + ); + + expect(screen.getByText('The project needs a database for persistence.')).toBeInTheDocument(); + }); + + it('disables submit button when answer is empty', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /answer blocker/i }); + expect(button).toBeDisabled(); + }); + + it('enables submit button when answer is typed', async () => { + const user = userEvent.setup(); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'PostgreSQL'); + + const button = screen.getByRole('button', { name: /answer blocker/i }); + expect(button).toBeEnabled(); + }); + + it('calls blockersApi.answer on submit and shows confirmation', async () => { + const user = userEvent.setup(); + const onAnswered = jest.fn(); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'PostgreSQL'); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + await waitFor(() => { + expect(mockAnswer).toHaveBeenCalledWith('/test', '42', 'PostgreSQL'); + }); + + expect(screen.getByText('Blocker answered. Execution resuming...')).toBeInTheDocument(); + expect(onAnswered).toHaveBeenCalled(); + }); + + it('shows error when API call fails', async () => { + const user = userEvent.setup(); + mockAnswer.mockRejectedValue({ detail: 'Blocker not found' }); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Some answer'); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + await waitFor(() => { + expect(screen.getByText('Blocker not found')).toBeInTheDocument(); + }); + + // Form should still be visible (not replaced with success) + expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument(); + }); + + it('trims whitespace from answer before submitting', async () => { + const user = userEvent.setup(); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, ' PostgreSQL '); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + await waitFor(() => { + expect(mockAnswer).toHaveBeenCalledWith('/test', '42', 'PostgreSQL'); + }); + }); + + it('does not submit when answer is only whitespace', async () => { + const user = userEvent.setup(); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, ' '); + + const button = screen.getByRole('button', { name: /answer blocker/i }); + expect(button).toBeDisabled(); + }); +}); diff --git a/web-ui/__tests__/components/execution/EventItem.test.tsx b/web-ui/__tests__/components/execution/EventItem.test.tsx new file mode 100644 index 00000000..9a2607e7 --- /dev/null +++ b/web-ui/__tests__/components/execution/EventItem.test.tsx @@ -0,0 +1,251 @@ +import { render, screen } from '@testing-library/react'; +import { EventItem } from '@/components/execution/EventItem'; +import type { + ProgressEvent, + OutputEvent, + CompletionEvent, + ErrorEvent, + HeartbeatEvent, + BlockerEvent as BlockerEventType, +} from '@/hooks/useTaskStream'; + +// ── Mock child components ───────────────────────────────────────────── + +jest.mock('@/components/execution/PlanningEvent', () => ({ + PlanningEvent: ({ event }: { event: ProgressEvent }) => ( +
{event.message}
+ ), +})); + +jest.mock('@/components/execution/FileChangeEvent', () => ({ + FileChangeEvent: ({ event }: { event: ProgressEvent }) => ( +
{event.message}
+ ), +})); + +jest.mock('@/components/execution/ShellCommandEvent', () => ({ + ShellCommandEvent: ({ event }: { event: OutputEvent }) => ( +
{event.line}
+ ), +})); + +jest.mock('@/components/execution/VerificationEvent', () => ({ + VerificationEvent: ({ event }: { event: ProgressEvent }) => ( +
{event.message}
+ ), +})); + +jest.mock('@/components/execution/BlockerEvent', () => ({ + BlockerEvent: ({ event }: { event: BlockerEventType }) => ( +
{event.question}
+ ), +})); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('EventItem', () => { + it('renders nothing for heartbeat events', () => { + const heartbeat: HeartbeatEvent = { + event_type: 'heartbeat', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + }; + + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders timestamp and Planning badge for planning progress', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T14:30:15Z', + phase: 'planning', + step: 1, + total_steps: 3, + message: 'Creating plan...', + }; + + render(); + + expect(screen.getByText('Planning')).toBeInTheDocument(); + expect(screen.getByTestId('planning-event')).toBeInTheDocument(); + expect(screen.getByText('Creating plan...')).toBeInTheDocument(); + }); + + it('delegates file change progress events to FileChangeEvent', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + phase: 'execution', + step: 2, + total_steps: 5, + message: 'Creating file: src/main.py', + }; + + render(); + + expect(screen.getByTestId('file-change-event')).toBeInTheDocument(); + }); + + it('delegates verification phase to VerificationEvent', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + phase: 'verification', + step: 1, + total_steps: 2, + message: 'Running ruff', + }; + + render(); + + expect(screen.getByTestId('verification-event')).toBeInTheDocument(); + }); + + it('delegates self_correction phase to VerificationEvent', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + phase: 'self_correction', + step: 1, + total_steps: 3, + message: 'Fixing lint errors', + }; + + render(); + + expect(screen.getByTestId('verification-event')).toBeInTheDocument(); + }); + + it('renders generic execution step for non-special progress events', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + phase: 'execution', + step: 3, + total_steps: 5, + message: 'Installing dependencies', + }; + + render(); + + expect(screen.getByText('Executing')).toBeInTheDocument(); + expect(screen.getByText('Installing dependencies')).toBeInTheDocument(); + expect(screen.getByText('Step 3/5')).toBeInTheDocument(); + }); + + it('delegates output events to ShellCommandEvent', () => { + const event: OutputEvent = { + event_type: 'output', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + stream: 'stdout', + line: 'test passed', + }; + + render(); + + expect(screen.getByTestId('shell-command-event')).toBeInTheDocument(); + expect(screen.getByText('test passed')).toBeInTheDocument(); + }); + + it('delegates blocker events to BlockerEvent', () => { + const event: BlockerEventType = { + event_type: 'blocker', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + blocker_id: 42, + question: 'Which database to use?', + }; + + render(); + + expect(screen.getByTestId('blocker-event')).toBeInTheDocument(); + expect(screen.getByText('Which database to use?')).toBeInTheDocument(); + }); + + it('renders completion event with success styling', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + status: 'completed', + duration_seconds: 45, + files_modified: ['a.py', 'b.py'], + }; + + render(); + + expect(screen.getByText('Task completed successfully')).toBeInTheDocument(); + expect(screen.getByText('(45s)')).toBeInTheDocument(); + expect(screen.getByText('2 files modified')).toBeInTheDocument(); + }); + + it('renders completion event with failed status', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + status: 'failed', + duration_seconds: 10, + }; + + render(); + + expect(screen.getByText('Task failed')).toBeInTheDocument(); + }); + + it('renders error event with error message and traceback', () => { + const event: ErrorEvent = { + event_type: 'error', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + error: 'Module not found', + error_type: 'ImportError', + traceback: 'File "main.py", line 1\nImportError: No module named foo', + }; + + render(); + + expect(screen.getByText('Module not found')).toBeInTheDocument(); + expect( + screen.getByText(/ImportError: No module named foo/) + ).toBeInTheDocument(); + }); + + it('renders error event without traceback', () => { + const event: ErrorEvent = { + event_type: 'error', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + error: 'Timeout', + error_type: 'TimeoutError', + }; + + render(); + + expect(screen.getByText('Timeout')).toBeInTheDocument(); + }); + + it('shows 1 file modified (singular) for single file', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + status: 'completed', + duration_seconds: 5, + files_modified: ['single.py'], + }; + + render(); + + expect(screen.getByText('1 file modified')).toBeInTheDocument(); + }); +}); diff --git a/web-ui/__tests__/components/layout/AppSidebar.test.tsx b/web-ui/__tests__/components/layout/AppSidebar.test.tsx index b0bd9c00..f010f3fa 100644 --- a/web-ui/__tests__/components/layout/AppSidebar.test.tsx +++ b/web-ui/__tests__/components/layout/AppSidebar.test.tsx @@ -81,9 +81,10 @@ describe('AppSidebar', () => { mockGetWorkspacePath.mockReturnValue('/home/user/projects/test'); render(); - // Tasks is now enabled; Execution, Blockers, Review are still disabled + // Tasks and Execution are now enabled; Blockers, Review are still disabled expect(screen.getByRole('link', { name: /^tasks$/i })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /^execution$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^execution$/i })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^blockers$/i })).not.toBeInTheDocument(); }); it('highlights the active route', () => { diff --git a/web-ui/__tests__/hooks/useExecutionMonitor.test.ts b/web-ui/__tests__/hooks/useExecutionMonitor.test.ts new file mode 100644 index 00000000..8af6495f --- /dev/null +++ b/web-ui/__tests__/hooks/useExecutionMonitor.test.ts @@ -0,0 +1,254 @@ +import { renderHook, act } from '@testing-library/react'; +import { useExecutionMonitor } from '@/hooks/useExecutionMonitor'; +import type { + ProgressEvent, + CompletionEvent, + ErrorEvent, + HeartbeatEvent, + OutputEvent, +} from '@/hooks/useTaskStream'; + +// ── Mock useTaskStream ──────────────────────────────────────────────── + +let capturedOnEvent: ((event: never) => void) | undefined; +let mockSSEStatus = 'open'; + +jest.mock('@/hooks/useTaskStream', () => ({ + useTaskStream: ({ onEvent }: { taskId: string | null; onEvent?: (event: never) => void }) => { + capturedOnEvent = onEvent; + return { status: mockSSEStatus, close: jest.fn() }; + }, +})); + +// Mock requestAnimationFrame with a queue so callbacks don't race +// with the return value assignment in scheduleFlush. +let rafQueue: FrameRequestCallback[] = []; +let rafId = 0; + +function flushRAF() { + const cbs = [...rafQueue]; + rafQueue = []; + cbs.forEach((cb) => cb(0)); +} + +/** Dispatch an event and flush the rAF queue in a single act(). */ +function dispatchAndFlush(event: unknown) { + act(() => { + capturedOnEvent?.(event as never); + flushRAF(); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + capturedOnEvent = undefined; + mockSSEStatus = 'open'; + rafQueue = []; + rafId = 0; + + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafQueue.push(cb); + return ++rafId; + }); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ── Fixtures ────────────────────────────────────────────────────────── + +function makeProgressEvent(overrides: Partial = {}): ProgressEvent { + return { + event_type: 'progress', + task_id: 'task-1', + timestamp: '2026-02-06T10:00:00Z', + phase: 'execution', + step: 1, + total_steps: 5, + message: 'Running step 1', + ...overrides, + }; +} + +function makeCompletionEvent(overrides: Partial = {}): CompletionEvent { + return { + event_type: 'completion', + task_id: 'task-1', + timestamp: '2026-02-06T10:05:00Z', + status: 'completed', + duration_seconds: 120, + files_modified: ['src/main.py', 'tests/test_main.py'], + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('useExecutionMonitor', () => { + it('starts with CONNECTING state and empty events', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + expect(result.current.agentState).toBe('CONNECTING'); + expect(result.current.events).toEqual([]); + expect(result.current.isCompleted).toBe(false); + expect(result.current.completionStatus).toBeNull(); + }); + + it('accumulates events and derives agent state from latest non-heartbeat', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeProgressEvent({ phase: 'planning', step: 1 })); + + expect(result.current.events).toHaveLength(1); + expect(result.current.agentState).toBe('PLANNING'); + + dispatchAndFlush(makeProgressEvent({ phase: 'execution', step: 2 })); + + expect(result.current.events).toHaveLength(2); + expect(result.current.agentState).toBe('EXECUTING'); + }); + + it('tracks progress step and total from latest ProgressEvent', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeProgressEvent({ step: 3, total_steps: 10, message: 'Building' })); + + expect(result.current.currentStep).toBe(3); + expect(result.current.totalSteps).toBe(10); + expect(result.current.currentMessage).toBe('Building'); + }); + + it('ignores heartbeats for agent state derivation', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeProgressEvent({ phase: 'verification' })); + + expect(result.current.agentState).toBe('VERIFICATION'); + + const heartbeat: HeartbeatEvent = { + event_type: 'heartbeat', + task_id: 'task-1', + timestamp: '2026-02-06T10:01:00Z', + }; + + dispatchAndFlush(heartbeat); + + // Heartbeat added to events but state still VERIFICATION + expect(result.current.events).toHaveLength(2); + expect(result.current.agentState).toBe('VERIFICATION'); + }); + + it('detects completion and stores status and duration', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeCompletionEvent()); + + expect(result.current.isCompleted).toBe(true); + expect(result.current.completionStatus).toBe('completed'); + expect(result.current.duration).toBe(120); + expect(result.current.agentState).toBe('COMPLETED'); + }); + + it('collects changed files from completion event', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeCompletionEvent({ files_modified: ['a.py', 'b.py', 'c.py'] })); + + expect(result.current.changedFiles).toEqual(['a.py', 'b.py', 'c.py']); + }); + + it('derives FAILED state from error events', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + const errorEvent: ErrorEvent = { + event_type: 'error', + task_id: 'task-1', + timestamp: '2026-02-06T10:02:00Z', + error: 'Module not found', + error_type: 'ImportError', + }; + + dispatchAndFlush(errorEvent); + + expect(result.current.agentState).toBe('FAILED'); + }); + + it('derives BLOCKED state from blocker events', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush({ + event_type: 'blocker', + task_id: 'task-1', + timestamp: '2026-02-06T10:02:00Z', + blocker_id: 42, + question: 'Which database?', + }); + + expect(result.current.agentState).toBe('BLOCKED'); + }); + + it('derives SELF_CORRECTING from self_correction phase', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeProgressEvent({ phase: 'self_correction' })); + + expect(result.current.agentState).toBe('SELF_CORRECTING'); + }); + + it('handles output events as EXECUTING state', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + const outputEvent: OutputEvent = { + event_type: 'output', + task_id: 'task-1', + timestamp: '2026-02-06T10:01:00Z', + stream: 'stdout', + line: 'Running tests...', + }; + + dispatchAndFlush(outputEvent); + + expect(result.current.agentState).toBe('EXECUTING'); + }); + + it('resets state when taskId changes', () => { + const { result, rerender } = renderHook( + ({ taskId }) => useExecutionMonitor(taskId), + { initialProps: { taskId: 'task-1' as string | null } } + ); + + dispatchAndFlush(makeProgressEvent({ step: 5, total_steps: 10 })); + + expect(result.current.events).toHaveLength(1); + expect(result.current.currentStep).toBe(5); + + // Switch to a different task + rerender({ taskId: 'task-2' }); + + expect(result.current.events).toEqual([]); + expect(result.current.currentStep).toBe(0); + expect(result.current.agentState).toBe('CONNECTING'); + }); + + it('handles failed completion status', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeCompletionEvent({ status: 'failed' })); + + expect(result.current.isCompleted).toBe(true); + expect(result.current.completionStatus).toBe('failed'); + expect(result.current.agentState).toBe('FAILED'); + }); + + it('handles blocked completion status', () => { + const { result } = renderHook(() => useExecutionMonitor('task-1')); + + dispatchAndFlush(makeCompletionEvent({ status: 'blocked' })); + + expect(result.current.isCompleted).toBe(true); + expect(result.current.completionStatus).toBe('blocked'); + expect(result.current.agentState).toBe('BLOCKED'); + }); +}); diff --git a/web-ui/__tests__/hooks/useTaskStream.test.ts b/web-ui/__tests__/hooks/useTaskStream.test.ts index d57cbb17..d0158596 100644 --- a/web-ui/__tests__/hooks/useTaskStream.test.ts +++ b/web-ui/__tests__/hooks/useTaskStream.test.ts @@ -21,7 +21,7 @@ describe('useTaskStream', () => { it('returns idle status when taskId is null', () => { const { result } = renderHook(() => - useTaskStream({ taskId: null }) + useTaskStream({ taskId: null, workspacePath: null }) ); expect(result.current.lastEvent).toBeNull(); }); @@ -29,7 +29,7 @@ describe('useTaskStream', () => { it('dispatches progress events to onProgress callback', () => { const onProgress = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onProgress }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onProgress }) ); const event: ProgressEvent = { @@ -54,7 +54,7 @@ describe('useTaskStream', () => { it('dispatches completion events to onComplete callback', () => { const onComplete = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onComplete }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onComplete }) ); const event: CompletionEvent = { @@ -78,7 +78,7 @@ describe('useTaskStream', () => { it('dispatches output events to onOutput callback', () => { const onOutput = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onOutput }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onOutput }) ); const event: OutputEvent = { @@ -101,7 +101,7 @@ describe('useTaskStream', () => { it('dispatches error events to onError callback', () => { const onError = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onError }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onError }) ); const event: ErrorEvent = { @@ -124,7 +124,7 @@ describe('useTaskStream', () => { it('calls onEvent for every event type', () => { const onEvent = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onEvent }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onEvent }) ); act(() => { @@ -144,7 +144,7 @@ describe('useTaskStream', () => { it('updates lastEvent on each message', () => { const { result } = renderHook(() => - useTaskStream({ taskId: 'task-1' }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws' }) ); expect(result.current.lastEvent).toBeNull(); @@ -169,7 +169,7 @@ describe('useTaskStream', () => { it('ignores malformed JSON messages', () => { const onEvent = jest.fn(); renderHook(() => - useTaskStream({ taskId: 'task-1', onEvent }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws', onEvent }) ); act(() => { @@ -181,7 +181,7 @@ describe('useTaskStream', () => { it('exposes close function', () => { const { result } = renderHook(() => - useTaskStream({ taskId: 'task-1' }) + useTaskStream({ taskId: 'task-1', workspacePath: '/tmp/ws' }) ); result.current.close(); diff --git a/web-ui/__tests__/lib/api.test.ts b/web-ui/__tests__/lib/api.test.ts index 49b118bf..ac540f56 100644 --- a/web-ui/__tests__/lib/api.test.ts +++ b/web-ui/__tests__/lib/api.test.ts @@ -48,10 +48,10 @@ describe('api client', () => { expect(result).toBe('error 1; error 2'); }); - it('extracts error from structured api_error object', () => { + it('combines error and detail from structured api_error object', () => { const structuredError = { error: 'Cannot execute', code: 'INVALID_STATE', detail: 'No tasks ready' }; const result = normalizeErrorDetail(structuredError, 'fallback'); - expect(result).toBe('Cannot execute'); + expect(result).toBe('Cannot execute: No tasks ready'); }); it('falls back to detail when structured error has no error field', () => { diff --git a/web-ui/__tests__/lib/eventStyles.test.ts b/web-ui/__tests__/lib/eventStyles.test.ts new file mode 100644 index 00000000..46f6aa76 --- /dev/null +++ b/web-ui/__tests__/lib/eventStyles.test.ts @@ -0,0 +1,202 @@ +import { deriveAgentState, agentStateBadgeStyles, agentStateLabels, agentStateIcons, connectionDotStyles } from '@/lib/eventStyles'; +import type { + ProgressEvent, + CompletionEvent, + ErrorEvent, + OutputEvent, + HeartbeatEvent, + BlockerEvent, +} from '@/hooks/useTaskStream'; + +// ── deriveAgentState ────────────────────────────────────────────────── + +describe('deriveAgentState', () => { + it('returns PLANNING for progress events with planning phase', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 't1', + timestamp: '', + phase: 'planning', + step: 1, + total_steps: 3, + message: '', + }; + expect(deriveAgentState(event)).toBe('PLANNING'); + }); + + it('returns EXECUTING for progress events with execution phase', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 't1', + timestamp: '', + phase: 'execution', + step: 1, + total_steps: 3, + message: '', + }; + expect(deriveAgentState(event)).toBe('EXECUTING'); + }); + + it('returns VERIFICATION for progress events with verification phase', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 't1', + timestamp: '', + phase: 'verification', + step: 1, + total_steps: 2, + message: '', + }; + expect(deriveAgentState(event)).toBe('VERIFICATION'); + }); + + it('returns SELF_CORRECTING for self_correction phase', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 't1', + timestamp: '', + phase: 'self_correction', + step: 1, + total_steps: 3, + message: '', + }; + expect(deriveAgentState(event)).toBe('SELF_CORRECTING'); + }); + + it('returns EXECUTING for unknown progress phases', () => { + const event: ProgressEvent = { + event_type: 'progress', + task_id: 't1', + timestamp: '', + phase: 'unknown_phase', + step: 1, + total_steps: 3, + message: '', + }; + expect(deriveAgentState(event)).toBe('EXECUTING'); + }); + + it('returns BLOCKED for blocker events', () => { + const event: BlockerEvent = { + event_type: 'blocker', + task_id: 't1', + timestamp: '', + blocker_id: 1, + question: 'test', + }; + expect(deriveAgentState(event)).toBe('BLOCKED'); + }); + + it('returns COMPLETED for successful completion events', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 't1', + timestamp: '', + status: 'completed', + duration_seconds: 10, + }; + expect(deriveAgentState(event)).toBe('COMPLETED'); + }); + + it('returns FAILED for failed completion events', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 't1', + timestamp: '', + status: 'failed', + duration_seconds: 5, + }; + expect(deriveAgentState(event)).toBe('FAILED'); + }); + + it('returns BLOCKED for blocked completion events', () => { + const event: CompletionEvent = { + event_type: 'completion', + task_id: 't1', + timestamp: '', + status: 'blocked', + duration_seconds: 5, + }; + expect(deriveAgentState(event)).toBe('BLOCKED'); + }); + + it('returns FAILED for error events', () => { + const event: ErrorEvent = { + event_type: 'error', + task_id: 't1', + timestamp: '', + error: 'some error', + error_type: 'RuntimeError', + }; + expect(deriveAgentState(event)).toBe('FAILED'); + }); + + it('returns EXECUTING for output events', () => { + const event: OutputEvent = { + event_type: 'output', + task_id: 't1', + timestamp: '', + stream: 'stdout', + line: 'hello', + }; + expect(deriveAgentState(event)).toBe('EXECUTING'); + }); + + it('returns EXECUTING for heartbeat events (default case)', () => { + const event: HeartbeatEvent = { + event_type: 'heartbeat', + task_id: 't1', + timestamp: '', + }; + expect(deriveAgentState(event)).toBe('EXECUTING'); + }); +}); + +// ── Style maps ──────────────────────────────────────────────────────── + +describe('style maps', () => { + it('has badge styles for all UIAgentState values', () => { + const states = [ + 'CONNECTING', 'PLANNING', 'EXECUTING', 'VERIFICATION', + 'SELF_CORRECTING', 'BLOCKED', 'COMPLETED', 'FAILED', 'DISCONNECTED', + ] as const; + + states.forEach((state) => { + expect(agentStateBadgeStyles[state]).toBeDefined(); + expect(typeof agentStateBadgeStyles[state]).toBe('string'); + }); + }); + + it('has labels for all UIAgentState values', () => { + const states = [ + 'CONNECTING', 'PLANNING', 'EXECUTING', 'VERIFICATION', + 'SELF_CORRECTING', 'BLOCKED', 'COMPLETED', 'FAILED', 'DISCONNECTED', + ] as const; + + states.forEach((state) => { + expect(agentStateLabels[state]).toBeDefined(); + expect(typeof agentStateLabels[state]).toBe('string'); + }); + }); + + it('has icons for all UIAgentState values', () => { + const states = [ + 'CONNECTING', 'PLANNING', 'EXECUTING', 'VERIFICATION', + 'SELF_CORRECTING', 'BLOCKED', 'COMPLETED', 'FAILED', 'DISCONNECTED', + ] as const; + + states.forEach((state) => { + expect(agentStateIcons[state]).toBeDefined(); + expect(agentStateIcons[state]).toBeTruthy(); + }); + }); + + it('has connection dot styles for all SSE statuses', () => { + const statuses = ['idle', 'connecting', 'open', 'closed', 'error']; + + statuses.forEach((status) => { + expect(connectionDotStyles[status]).toBeDefined(); + expect(typeof connectionDotStyles[status]).toBe('string'); + }); + }); +}); diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 723b8eef..b087048d 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -9,8 +9,11 @@ "version": "0.1.0", "dependencies": { "@hugeicons/react": "^0.3.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.1.13", @@ -2248,6 +2251,52 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -2650,6 +2699,68 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -2681,6 +2792,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index bf727ceb..b00c2e5c 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -13,8 +13,11 @@ }, "dependencies": { "@hugeicons/react": "^0.3.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.1.13", diff --git a/web-ui/src/app/execution/[taskId]/page.tsx b/web-ui/src/app/execution/[taskId]/page.tsx new file mode 100644 index 00000000..b8fb3401 --- /dev/null +++ b/web-ui/src/app/execution/[taskId]/page.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useExecutionMonitor } from '@/hooks/useExecutionMonitor'; +import { tasksApi } from '@/lib/api'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import { ExecutionHeader } from '@/components/execution/ExecutionHeader'; +import { ProgressIndicator } from '@/components/execution/ProgressIndicator'; +import { EventStream } from '@/components/execution/EventStream'; +import { ChangesSidebar } from '@/components/execution/ChangesSidebar'; +import type { Task } from '@/types'; + +export default function ExecutionPage() { + const params = useParams<{ taskId: string }>(); + const router = useRouter(); + const taskId = params.taskId; + + const [workspacePath, setWorkspacePath] = useState(null); + const [workspaceReady, setWorkspaceReady] = useState(false); + const [task, setTask] = useState(null); + const [taskError, setTaskError] = useState(false); + + // Hydrate workspace path from localStorage + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + setWorkspaceReady(true); + }, []); + + // Fetch task details + useEffect(() => { + if (!workspacePath || !taskId) return; + tasksApi.getOne(workspacePath, taskId).then(setTask).catch((err) => { + console.error('Failed to load task:', err); + setTaskError(true); + }); + }, [workspacePath, taskId]); + + // Connect to SSE stream + const monitor = useExecutionMonitor( + workspaceReady && taskId ? taskId : null, + workspacePath + ); + + // Stop handler — may fail if run already completed or no active run + const handleStop = useCallback(async () => { + if (!workspacePath || !taskId) return; + try { + await tasksApi.stopExecution(workspacePath, taskId); + } catch (err) { + const detail = (err as { detail?: string })?.detail ?? ''; + // 400/404 from stop is expected if run already completed + if (!detail.includes('not found') && !detail.includes('Cannot stop')) { + console.error('Failed to stop execution:', detail); + } + } + }, [workspacePath, taskId]); + + // Re-fetch blocker list when a blocker is answered inline + const handleBlockerAnswered = useCallback(() => { + // After answering a blocker the SSE stream will resume automatically. + // No extra action needed — the stream pushes new events. + }, []); + + // ── Guards ────────────────────────────────────────────────────────── + + if (!workspaceReady) return null; + + if (taskError) { + return ( +
+

Task not found or failed to load.

+ Back to Task Board +
+ ); + } + + if (!workspacePath) { + return ( +
+
+
+

+ No workspace selected.{' '} + + Select a workspace + {' '} + first. +

+
+
+
+ ); + } + + // ── Layout ────────────────────────────────────────────────────────── + + return ( +
+
+ {/* Header: task info + agent state + stop button */} + + + {/* Progress bar */} + + + {/* SSE disconnection banner */} + {monitor.agentState === 'DISCONNECTED' && !monitor.isCompleted && ( +
+ Connection lost. Events may be missing. + +
+ )} + + {/* Completion banner */} + {monitor.isCompleted && ( + router.push('/review')} + onBackToTasks={() => router.push('/tasks')} + /> + )} + + {/* Main content: event stream + changes sidebar */} +
+ + +
+
+
+ ); +} + +// ── Completion Banner ───────────────────────────────────────────────── + +function CompletionBanner({ + status, + duration, + onViewChanges, + onBackToTasks, +}: { + status: 'completed' | 'failed' | 'blocked' | null; + duration: number | null; + onViewChanges: () => void; + onBackToTasks: () => void; +}) { + const durationText = duration !== null ? `${Math.round(duration)}s` : ''; + + if (status === 'completed') { + return ( +
+

+ Execution completed successfully{durationText && ` in ${durationText}`}. +

+
+ + +
+
+ ); + } + + if (status === 'failed') { + return ( +
+

+ Execution failed{durationText && ` after ${durationText}`}. Check the + event stream for details. +

+ +
+ ); + } + + if (status === 'blocked') { + return ( +
+

+ Execution blocked — a blocker was raised. Answer it in the event + stream below to continue. +

+ +
+ ); + } + + return null; +} diff --git a/web-ui/src/app/execution/page.tsx b/web-ui/src/app/execution/page.tsx new file mode 100644 index 00000000..ab6ffc07 --- /dev/null +++ b/web-ui/src/app/execution/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { Suspense, useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Loading03Icon } from '@hugeicons/react'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import { tasksApi } from '@/lib/api'; +import { BatchExecutionMonitor } from '@/components/execution/BatchExecutionMonitor'; + +/** + * Execution landing page wrapper. + * + * Wraps the main content in `` because `useSearchParams()` + * triggers a client-side rendering bailout in Next.js App Router. + */ +export default function ExecutionLandingPage() { + return ( + + + + } + > + + + ); +} + +/** + * Inner content that reads search params. + * + * Routing logic: + * - `?batch=` → renders BatchExecutionMonitor + * - `?task=` → redirects to /execution/[taskId] + * - No params → finds latest IN_PROGRESS task and redirects, or shows empty state + */ +function ExecutionLandingContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + + const batchId = searchParams.get('batch'); + const taskIdParam = searchParams.get('task'); + + const [workspacePath, setWorkspacePath] = useState(null); + const [workspaceReady, setWorkspaceReady] = useState(false); + const [resolving, setResolving] = useState(true); + + // Hydrate workspace path + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + setWorkspaceReady(true); + }, []); + + // If ?task= is present, redirect immediately + useEffect(() => { + if (taskIdParam) { + router.replace(`/execution/${taskIdParam}`); + } + }, [taskIdParam, router]); + + // If no batch and no task param, find latest IN_PROGRESS task + useEffect(() => { + if (batchId || taskIdParam || !workspacePath) { + setResolving(false); + return; + } + + tasksApi + .getAll(workspacePath, 'IN_PROGRESS') + .then((response) => { + const tasks = response.tasks ?? []; + if (tasks.length > 0) { + router.replace(`/execution/${tasks[0].id}`); + } else { + setResolving(false); + } + }) + .catch(() => { + setResolving(false); + }); + }, [workspacePath, batchId, taskIdParam, router]); + + // ── Guards ────────────────────────────────────────────────────────── + + if (!workspaceReady) return null; + + if (!workspacePath) { + return ( +
+
+
+

+ No workspace selected.{' '} + + Select a workspace + {' '} + first. +

+
+
+
+ ); + } + + // Redirecting to single-task page + if (taskIdParam || resolving) { + return ( +
+ +
+ ); + } + + // Batch mode + if (batchId) { + return ( +
+
+ +
+
+ ); + } + + // No active execution — empty state + return ( +
+
+
+

+ No active execution +

+

+ Start an execution from the{' '} + + Task Board + + . +

+
+
+
+ ); +} diff --git a/web-ui/src/components/execution/BatchExecutionMonitor.tsx b/web-ui/src/components/execution/BatchExecutionMonitor.tsx new file mode 100644 index 00000000..cf560bd9 --- /dev/null +++ b/web-ui/src/components/execution/BatchExecutionMonitor.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { + CheckmarkCircle01Icon, + Cancel01Icon, + Loading03Icon, + Alert02Icon, + StopIcon, +} from '@hugeicons/react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { batchesApi, tasksApi } from '@/lib/api'; +import { EventStream } from './EventStream'; +import { useExecutionMonitor } from '@/hooks/useExecutionMonitor'; +import type { BatchResponse, Task } from '@/types'; + +// ── Status icon helper ──────────────────────────────────────────────── + +const statusConfig: Record = { + COMPLETED: { icon: CheckmarkCircle01Icon, className: 'text-green-600', label: 'Completed' }, + DONE: { icon: CheckmarkCircle01Icon, className: 'text-green-600', label: 'Done' }, + FAILED: { icon: Cancel01Icon, className: 'text-red-600', label: 'Failed' }, + IN_PROGRESS: { icon: Loading03Icon, className: 'text-blue-600 animate-spin', label: 'Running' }, + BLOCKED: { icon: Alert02Icon, className: 'text-amber-600', label: 'Blocked' }, + READY: { icon: Loading03Icon, className: 'text-gray-400', label: 'Waiting' }, +}; + +function getStatusConfig(status: string) { + return statusConfig[status] ?? { icon: Loading03Icon, className: 'text-gray-400', label: status }; +} + +// ── Props ───────────────────────────────────────────────────────────── + +interface BatchExecutionMonitorProps { + batchId: string; + workspacePath: string; +} + +export function BatchExecutionMonitor({ batchId, workspacePath }: BatchExecutionMonitorProps) { + const router = useRouter(); + const [batch, setBatch] = useState(null); + const [tasks, setTasks] = useState>({}); + const [expandedTaskId, setExpandedTaskId] = useState(null); + const [error, setError] = useState(null); + const pollRef = useRef | null>(null); + + // Track which task IDs have already been fetched to avoid refetching + const fetchedTaskIdsRef = useRef>(new Set()); + + // ── Fetch batch details + task names ──────────────────────────────── + const fetchBatch = useCallback(async () => { + try { + setError(null); + const data = await batchesApi.get(workspacePath, batchId); + setBatch(data); + + // Fetch task details for any new task IDs (check ref, not state) + for (const taskId of data.task_ids) { + if (!fetchedTaskIdsRef.current.has(taskId)) { + fetchedTaskIdsRef.current.add(taskId); + tasksApi.getOne(workspacePath, taskId).then((task) => { + setTasks((prev) => ({ ...prev, [taskId]: task })); + }).catch((err) => { + console.error(`Failed to fetch task ${taskId}:`, err); + }); + } + } + } catch { + setError('Failed to load batch details'); + } + }, [workspacePath, batchId]); + + // Initial fetch + useEffect(() => { + fetchBatch(); + }, [batchId, workspacePath]); // eslint-disable-line react-hooks/exhaustive-deps + + // Poll every 5 seconds while batch is active + useEffect(() => { + const isActive = batch && !['COMPLETED', 'FAILED', 'CANCELLED'].includes(batch.status); + if (isActive) { + pollRef.current = setInterval(fetchBatch, 5000); + } + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [batch?.status, fetchBatch]); + + // Auto-expand the first IN_PROGRESS task + useEffect(() => { + if (!batch || expandedTaskId) return; + const inProgress = batch.task_ids.find( + (id) => batch.results[id] === 'IN_PROGRESS' + ); + if (inProgress) setExpandedTaskId(inProgress); + }, [batch, expandedTaskId]); + + // ── Batch controls ────────────────────────────────────────────────── + const handleStop = useCallback(async () => { + try { + await batchesApi.stop(workspacePath, batchId); + fetchBatch(); + } catch { + setError('Failed to stop batch'); + } + }, [workspacePath, batchId, fetchBatch]); + + const handleCancel = useCallback(async () => { + try { + await batchesApi.cancel(workspacePath, batchId); + fetchBatch(); + } catch { + setError('Failed to cancel batch'); + } + }, [workspacePath, batchId, fetchBatch]); + + // ── Render ────────────────────────────────────────────────────────── + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!batch) { + return ( +
+ +
+ ); + } + + const isActive = !['COMPLETED', 'FAILED', 'CANCELLED'].includes(batch.status); + const completedCount = batch.task_ids.filter( + (id) => batch.results[id] === 'COMPLETED' || batch.results[id] === 'DONE' + ).length; + + return ( +
+ {/* Header */} +
+
+

+ Batch Execution ({batch.task_ids.length} tasks) +

+

+ Strategy: {batch.strategy} · {completedCount}/{batch.task_ids.length} complete +

+
+ + {isActive && ( +
+ + + + + + + Stop Batch? + + This will stop all currently running tasks in this batch. + Completed tasks will not be affected. + + + + Cancel + + Stop Batch + + + + + + + + + + + + Cancel Batch? + + This will cancel the entire batch, stopping all running + tasks and skipping remaining ones. + + + + Keep Running + + Cancel Batch + + + + +
+ )} + + {!isActive && ( + + )} +
+ + {/* Task rows */} +
+ {batch.task_ids.map((taskId) => ( + + setExpandedTaskId(expandedTaskId === taskId ? null : taskId) + } + workspacePath={workspacePath} + /> + ))} +
+
+ ); +} + +// ── Batch Task Row ──────────────────────────────────────────────────── + +function BatchTaskRow({ + taskId, + task, + status, + isExpanded, + onToggle, + workspacePath, +}: { + taskId: string; + task: Task | null; + status: string; + isExpanded: boolean; + onToggle: () => void; + workspacePath: string; +}) { + const config = getStatusConfig(status); + const StatusIcon = config.icon; + + // Only connect SSE when expanded and task is in progress or blocked + const shouldStream = isExpanded && (status === 'IN_PROGRESS' || status === 'BLOCKED'); + const monitor = useExecutionMonitor(shouldStream ? taskId : null, workspacePath); + + return ( +
+ {/* Row header */} + + + {/* Expanded event stream */} + {isExpanded && ( +
+ {shouldStream ? ( +
+ +
+ ) : ( +

+ {status === 'COMPLETED' || status === 'DONE' + ? 'Task completed successfully.' + : status === 'FAILED' + ? 'Task failed. Check diagnostics for details.' + : 'Waiting to start...'} +

+ )} +
+ )} +
+ ); +} diff --git a/web-ui/src/components/execution/BlockerEvent.tsx b/web-ui/src/components/execution/BlockerEvent.tsx new file mode 100644 index 00000000..9fa5253b --- /dev/null +++ b/web-ui/src/components/execution/BlockerEvent.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { Alert02Icon, Loading03Icon } from '@hugeicons/react'; +import { Button } from '@/components/ui/button'; +import { blockersApi } from '@/lib/api'; +import type { BlockerEvent as BlockerEventType } from '@/hooks/useTaskStream'; +import type { ApiError } from '@/types'; + +interface BlockerEventProps { + event: BlockerEventType; + workspacePath: string; + onAnswered?: () => void; +} + +/** + * Renders a blocker as an interrupt pattern with an inline answer form. + * + * Matches the architecture doc Section 4 "Interrupt Pattern for Blockers": + * highlighted card, question text, textarea, and submit button. + */ +export function BlockerEvent({ event, workspacePath, onAnswered }: BlockerEventProps) { + const [answer, setAnswer] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!answer.trim() || isSubmitting) return; + setIsSubmitting(true); + setError(null); + try { + await blockersApi.answer(workspacePath, String(event.blocker_id), answer.trim()); + setSubmitted(true); + onAnswered?.(); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr.detail || 'Failed to submit answer'); + } finally { + setIsSubmitting(false); + } + }; + + if (submitted) { + return ( +
+

+ Blocker answered. Execution resuming... +

+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Agent needs your help + +
+ + {/* Question */} +

{event.question}

+ + {/* Context (if available) */} + {event.context && ( +

{event.context}

+ )} + + {/* Answer form */} +