From 87a3fc43d84508b7398dd923a0fa8fecad79d944 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 6 Feb 2026 09:32:46 -0700 Subject: [PATCH 01/12] feat(web-ui): add Execution Monitor View for real-time task monitoring (#332) Implements Steps 1-12 of the execution monitor plan: - Bridge navigation gap: Task Board execute buttons now navigate to /execution - Add Blocker, BatchResponse, and UIAgentState types - Extend API client with blockersApi, batchesApi, and execution controls - Create event styling utilities with agent state derivation - Build 6 event display components (planning, file change, shell, verification, blocker, base item) - Build 4 container components (header, progress, event stream, changes sidebar) - Add 3 Shadcn UI primitives (alert-dialog, progress, scroll-area) - Create useExecutionMonitor hook with rAF-batched state accumulation - Create single-task execution page at /execution/[taskId] - Create execution landing page with batch/task routing - Create BatchExecutionMonitor with accordion task rows - Enable Execution nav link in sidebar - Add barrel export for all execution components --- web-ui/package-lock.json | 142 +++++++++ web-ui/package.json | 3 + web-ui/src/app/execution/[taskId]/page.tsx | 195 ++++++++++++ web-ui/src/app/execution/page.tsx | 149 +++++++++ .../execution/BatchExecutionMonitor.tsx | 299 ++++++++++++++++++ .../src/components/execution/BlockerEvent.tsx | 108 +++++++ .../components/execution/ChangesSidebar.tsx | 68 ++++ web-ui/src/components/execution/EventItem.tsx | 168 ++++++++++ .../src/components/execution/EventStream.tsx | 115 +++++++ .../components/execution/ExecutionHeader.tsx | 132 ++++++++ .../components/execution/FileChangeEvent.tsx | 48 +++ .../components/execution/PlanningEvent.tsx | 26 ++ .../execution/ProgressIndicator.tsx | 44 +++ .../execution/ShellCommandEvent.tsx | 50 +++ .../execution/VerificationEvent.tsx | 32 ++ web-ui/src/components/execution/index.ts | 11 + web-ui/src/components/layout/AppSidebar.tsx | 2 +- web-ui/src/components/tasks/TaskBoardView.tsx | 12 +- web-ui/src/components/ui/alert-dialog.tsx | 141 +++++++++ web-ui/src/components/ui/progress.tsx | 28 ++ web-ui/src/components/ui/scroll-area.tsx | 48 +++ web-ui/src/hooks/useExecutionMonitor.ts | 187 +++++++++++ web-ui/src/lib/api.ts | 102 ++++++ web-ui/src/lib/eventStyles.ts | 133 ++++++++ web-ui/src/types/index.ts | 48 +++ 25 files changed, 2285 insertions(+), 6 deletions(-) create mode 100644 web-ui/src/app/execution/[taskId]/page.tsx create mode 100644 web-ui/src/app/execution/page.tsx create mode 100644 web-ui/src/components/execution/BatchExecutionMonitor.tsx create mode 100644 web-ui/src/components/execution/BlockerEvent.tsx create mode 100644 web-ui/src/components/execution/ChangesSidebar.tsx create mode 100644 web-ui/src/components/execution/EventItem.tsx create mode 100644 web-ui/src/components/execution/EventStream.tsx create mode 100644 web-ui/src/components/execution/ExecutionHeader.tsx create mode 100644 web-ui/src/components/execution/FileChangeEvent.tsx create mode 100644 web-ui/src/components/execution/PlanningEvent.tsx create mode 100644 web-ui/src/components/execution/ProgressIndicator.tsx create mode 100644 web-ui/src/components/execution/ShellCommandEvent.tsx create mode 100644 web-ui/src/components/execution/VerificationEvent.tsx create mode 100644 web-ui/src/components/execution/index.ts create mode 100644 web-ui/src/components/ui/alert-dialog.tsx create mode 100644 web-ui/src/components/ui/progress.tsx create mode 100644 web-ui/src/components/ui/scroll-area.tsx create mode 100644 web-ui/src/hooks/useExecutionMonitor.ts create mode 100644 web-ui/src/lib/eventStyles.ts 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..0e6fca12 --- /dev/null +++ b/web-ui/src/app/execution/[taskId]/page.tsx @@ -0,0 +1,195 @@ +'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); + + // 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(() => { + // Task not found — task may have been deleted + }); + }, [workspacePath, taskId]); + + // Connect to SSE stream + const monitor = useExecutionMonitor(workspaceReady && taskId ? taskId : null); + + // Stop handler + const handleStop = useCallback(async () => { + if (!workspacePath || !taskId) return; + await tasksApi.stopExecution(workspacePath, taskId); + }, [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 (!workspacePath) { + return ( +
+
+
+

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

+
+
+
+ ); + } + + // ── Layout ────────────────────────────────────────────────────────── + + return ( +
+
+ {/* Header: task info + agent state + stop button */} + + + {/* Progress bar */} + + + {/* 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..b0e45545 --- /dev/null +++ b/web-ui/src/components/execution/BatchExecutionMonitor.tsx @@ -0,0 +1,299 @@ +'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); + + // ── Fetch batch details + task names ──────────────────────────────── + const fetchBatch = useCallback(async () => { + try { + const data = await batchesApi.get(workspacePath, batchId); + setBatch(data); + + // Fetch task details for any new task IDs + for (const taskId of data.task_ids) { + if (!tasks[taskId]) { + tasksApi.getOne(workspacePath, taskId).then((task) => { + setTasks((prev) => ({ ...prev, [taskId]: task })); + }).catch(() => { + // Task may have been deleted + }); + } + } + } catch { + setError('Failed to load batch details'); + } + }, [workspacePath, batchId, tasks]); + + // 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 () => { + await batchesApi.stop(workspacePath, batchId); + fetchBatch(); + }, [workspacePath, batchId, fetchBatch]); + + const handleCancel = useCallback(async () => { + await batchesApi.cancel(workspacePath, batchId); + fetchBatch(); + }, [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 + const shouldStream = isExpanded && status === 'IN_PROGRESS'; + const monitor = useExecutionMonitor(shouldStream ? taskId : null); + + 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..de2a7890 --- /dev/null +++ b/web-ui/src/components/execution/BlockerEvent.tsx @@ -0,0 +1,108 @@ +'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 */} +