From e79c623de304c6992de68deb6aa55bf36e7986c7 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:30:13 -0700 Subject: [PATCH 01/11] feat(TaskCard): add Stop and Reset action buttons for IN_PROGRESS and FAILED tasks - Add onStop and onReset optional props to TaskCardProps - Show Stop button with Cancel01Icon (destructive ghost styling) for IN_PROGRESS tasks - Show Reset button with ArrowTurnBackwardIcon for FAILED tasks - Both buttons use e.stopPropagation() to avoid triggering card onClick - Add 7 new tests covering button visibility, callbacks, and styling --- .../components/tasks/TaskCard.test.tsx | 46 +++++++++++++++++++ web-ui/src/components/tasks/TaskCard.tsx | 36 ++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/web-ui/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/__tests__/components/tasks/TaskCard.test.tsx index 86ab8d41..a16e3c56 100644 --- a/web-ui/__tests__/components/tasks/TaskCard.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskCard.test.tsx @@ -25,6 +25,8 @@ const defaultHandlers = { onClick: jest.fn(), onExecute: jest.fn(), onMarkReady: jest.fn(), + onStop: jest.fn(), + onReset: jest.fn(), }; function renderCard(taskOverrides: Partial = {}, props: Partial[0]> = {}) { @@ -155,4 +157,48 @@ describe('TaskCard', () => { unmount(); }); }); + + // ─── Stop / Reset action tests ────────────────────────────────────── + + it('shows Stop button for IN_PROGRESS tasks', () => { + renderCard({ status: 'IN_PROGRESS' }); + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + }); + + it('does not show Stop button for non-IN_PROGRESS tasks', () => { + renderCard({ status: 'READY' }); + expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument(); + }); + + it('calls onStop without triggering onClick', async () => { + const user = userEvent.setup(); + renderCard({ status: 'IN_PROGRESS' }); + await user.click(screen.getByRole('button', { name: /stop/i })); + expect(defaultHandlers.onStop).toHaveBeenCalledWith('task-1'); + expect(defaultHandlers.onClick).not.toHaveBeenCalled(); + }); + + it('shows Reset button for FAILED tasks', () => { + renderCard({ status: 'FAILED' }); + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); + + it('does not show Reset button for non-FAILED tasks', () => { + renderCard({ status: 'READY' }); + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); + + it('calls onReset without triggering onClick', async () => { + const user = userEvent.setup(); + renderCard({ status: 'FAILED' }); + await user.click(screen.getByRole('button', { name: /reset/i })); + expect(defaultHandlers.onReset).toHaveBeenCalledWith('task-1'); + expect(defaultHandlers.onClick).not.toHaveBeenCalled(); + }); + + it('Stop button has destructive ghost styling', () => { + renderCard({ status: 'IN_PROGRESS' }); + const stopBtn = screen.getByRole('button', { name: /stop/i }); + expect(stopBtn).toHaveClass('text-destructive'); + }); }); diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index e68769f8..50eeda49 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon } from '@hugeicons/react'; +import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Cancel01Icon, ArrowTurnBackwardIcon } from '@hugeicons/react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -37,6 +37,8 @@ interface TaskCardProps { onClick: (taskId: string) => void; onExecute: (taskId: string) => void; onMarkReady: (taskId: string) => void; + onStop?: (taskId: string) => void; + onReset?: (taskId: string) => void; } export function TaskCard({ @@ -47,6 +49,8 @@ export function TaskCard({ onClick, onExecute, onMarkReady, + onStop, + onReset, }: TaskCardProps) { return ( {task.status === 'READY' && ( + )} + {task.status === 'FAILED' && onReset && ( + + )} )} From 4ab5a7ee811803bba7eb1e4a6e147c267df0fed9 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:32:29 -0700 Subject: [PATCH 02/11] feat(TaskBoard): wire onStop and onReset handlers through component hierarchy - Add onStop/onReset props to TaskColumn and TaskBoardContent - Implement handleStop (calls tasksApi.stopExecution) in TaskBoardView - Implement handleReset (calls tasksApi.updateStatus READY) in TaskBoardView - Pass handlers through TaskBoardContent -> TaskColumn -> TaskCard - Add 5 integration tests for stop/reset actions and error handling --- .../components/tasks/TaskBoardView.test.tsx | 60 ++++++++++++++++++- .../src/components/tasks/TaskBoardContent.tsx | 6 ++ web-ui/src/components/tasks/TaskBoardView.tsx | 30 ++++++++++ web-ui/src/components/tasks/TaskColumn.tsx | 6 ++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx index dd06c1e8..1fb6d8e6 100644 --- a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TaskBoardView } from '@/components/tasks/TaskBoardView'; import type { Task, TaskListResponse } from '@/types'; @@ -12,6 +12,7 @@ jest.mock('@/lib/api', () => ({ updateStatus: jest.fn(), startExecution: jest.fn(), executeBatch: jest.fn(), + stopExecution: jest.fn(), }, })); @@ -226,4 +227,61 @@ describe('TaskBoardView', () => { render(); expect(screen.getByText('1 task total')).toBeInTheDocument(); }); + + it('shows Stop button on IN_PROGRESS task cards', () => { + render(); + // t3 is IN_PROGRESS - should have a Stop button + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + }); + + it('shows Reset button on FAILED task cards', () => { + render(); + // t6 is FAILED - should have a Reset button + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); + + it('calls stopExecution and mutates when Stop is clicked', async () => { + const { tasksApi } = require('@/lib/api'); + tasksApi.stopExecution.mockResolvedValue(undefined); + mockMutate.mockResolvedValue(undefined); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + act(() => { jest.advanceTimersByTime(350); }); + + await user.click(screen.getByRole('button', { name: /stop/i })); + + expect(tasksApi.stopExecution).toHaveBeenCalledWith('/test', 't3'); + expect(mockMutate).toHaveBeenCalled(); + }); + + it('calls updateStatus(READY) and mutates when Reset is clicked', async () => { + const { tasksApi } = require('@/lib/api'); + tasksApi.updateStatus.mockResolvedValue({}); + mockMutate.mockResolvedValue(undefined); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + act(() => { jest.advanceTimersByTime(350); }); + + await user.click(screen.getByRole('button', { name: /reset/i })); + + expect(tasksApi.updateStatus).toHaveBeenCalledWith('/test', 't6', 'READY'); + expect(mockMutate).toHaveBeenCalled(); + }); + + it('shows error banner when stop fails', async () => { + const { tasksApi } = require('@/lib/api'); + tasksApi.stopExecution.mockRejectedValue({ detail: 'Task not running' }); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + act(() => { jest.advanceTimersByTime(350); }); + + await user.click(screen.getByRole('button', { name: /stop/i })); + + await waitFor(() => { + expect(screen.getByText('Task not running')).toBeInTheDocument(); + }); + }); }); diff --git a/web-ui/src/components/tasks/TaskBoardContent.tsx b/web-ui/src/components/tasks/TaskBoardContent.tsx index bfdcde16..066c8a60 100644 --- a/web-ui/src/components/tasks/TaskBoardContent.tsx +++ b/web-ui/src/components/tasks/TaskBoardContent.tsx @@ -22,6 +22,8 @@ interface TaskBoardContentProps { onToggleSelect: (taskId: string) => void; onExecute: (taskId: string) => void; onMarkReady: (taskId: string) => void; + onStop?: (taskId: string) => void; + onReset?: (taskId: string) => void; } export function TaskBoardContent({ @@ -32,6 +34,8 @@ export function TaskBoardContent({ onToggleSelect, onExecute, onMarkReady, + onStop, + onReset, }: TaskBoardContentProps) { /** Group flat task array into per-status buckets. */ const tasksByStatus = useMemo(() => { @@ -60,6 +64,8 @@ export function TaskBoardContent({ onToggleSelect={onToggleSelect} onExecute={onExecute} onMarkReady={onMarkReady} + onStop={onStop} + onReset={onReset} /> ))} diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 9c6c9fdb..ef3cbd4b 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -126,6 +126,34 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { [workspacePath, mutate] ); + const handleStop = useCallback( + async (taskId: string) => { + setActionError(null); + try { + await tasksApi.stopExecution(workspacePath, taskId); + await mutate(); + } catch (err) { + const apiErr = err as ApiError; + setActionError(apiErr.detail || 'Failed to stop task'); + } + }, + [workspacePath, mutate] + ); + + const handleReset = useCallback( + async (taskId: string) => { + setActionError(null); + try { + await tasksApi.updateStatus(workspacePath, taskId, 'READY'); + await mutate(); + } catch (err) { + const apiErr = err as ApiError; + setActionError(apiErr.detail || 'Failed to reset task'); + } + }, + [workspacePath, mutate] + ); + const handleExecuteBatch = useCallback(async () => { if (selectedTaskIds.size === 0) return; setIsExecuting(true); @@ -225,6 +253,8 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onToggleSelect={handleToggleSelect} onExecute={handleExecute} onMarkReady={handleMarkReady} + onStop={handleStop} + onReset={handleReset} /> {/* Task detail modal */} diff --git a/web-ui/src/components/tasks/TaskColumn.tsx b/web-ui/src/components/tasks/TaskColumn.tsx index e6cac360..b1ae0dda 100644 --- a/web-ui/src/components/tasks/TaskColumn.tsx +++ b/web-ui/src/components/tasks/TaskColumn.tsx @@ -24,6 +24,8 @@ interface TaskColumnProps { onToggleSelect: (taskId: string) => void; onExecute: (taskId: string) => void; onMarkReady: (taskId: string) => void; + onStop?: (taskId: string) => void; + onReset?: (taskId: string) => void; } export function TaskColumn({ @@ -35,6 +37,8 @@ export function TaskColumn({ onToggleSelect, onExecute, onMarkReady, + onStop, + onReset, }: TaskColumnProps) { return (
@@ -65,6 +69,8 @@ export function TaskColumn({ onClick={onTaskClick} onExecute={onExecute} onMarkReady={onMarkReady} + onStop={onStop} + onReset={onReset} /> )) )} From 94c0d0f89e6e4a5fabd5285daac7b54dbfdf1098 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:33:26 -0700 Subject: [PATCH 03/11] feat(BulkActionConfirmDialog): create confirmation dialog for bulk task actions - AlertDialog-based component for execute/stop/reset bulk actions - Destructive styling for stop action confirm button - Loading state with spinner on confirm button - 8 tests covering all action types, callbacks, and states --- .../tasks/BulkActionConfirmDialog.test.tsx | 67 +++++++++++++++ .../tasks/BulkActionConfirmDialog.tsx | 84 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx create mode 100644 web-ui/src/components/tasks/BulkActionConfirmDialog.tsx diff --git a/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx b/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx new file mode 100644 index 00000000..5f863c64 --- /dev/null +++ b/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BulkActionConfirmDialog } from '@/components/tasks/BulkActionConfirmDialog'; + +const defaultProps = { + open: true, + onOpenChange: jest.fn(), + actionType: 'execute' as const, + taskCount: 3, + onConfirm: jest.fn(), + isLoading: false, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('BulkActionConfirmDialog', () => { + it('renders execute confirmation with correct title and description', () => { + render(); + expect(screen.getByText('Execute Tasks')).toBeInTheDocument(); + expect(screen.getByText(/execute 5 task\(s\)/i)).toBeInTheDocument(); + }); + + it('renders stop confirmation with correct title and description', () => { + render(); + expect(screen.getByText('Stop Tasks')).toBeInTheDocument(); + expect(screen.getByText(/stop 2 running task\(s\)/i)).toBeInTheDocument(); + }); + + it('renders reset confirmation with correct title and description', () => { + render(); + expect(screen.getByText('Reset Tasks')).toBeInTheDocument(); + expect(screen.getByText(/reset 4 failed task\(s\)/i)).toBeInTheDocument(); + }); + + it('calls onConfirm when confirm button is clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /confirm/i })); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onOpenChange(false) when cancel button is clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false); + }); + + it('disables confirm button when isLoading is true', () => { + render(); + const confirmBtn = screen.getByRole('button', { name: /confirm/i }); + expect(confirmBtn).toBeDisabled(); + }); + + it('does not render when open is false', () => { + render(); + expect(screen.queryByText('Execute Tasks')).not.toBeInTheDocument(); + }); + + it('shows destructive styling for stop action confirm button', () => { + render(); + const confirmBtn = screen.getByRole('button', { name: /confirm/i }); + expect(confirmBtn).toHaveClass('bg-destructive'); + }); +}); diff --git a/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx b/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx new file mode 100644 index 00000000..f7d5c0d2 --- /dev/null +++ b/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { Loading03Icon } from '@hugeicons/react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +export type BulkActionType = 'execute' | 'stop' | 'reset'; + +interface BulkActionConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + actionType: BulkActionType; + taskCount: number; + onConfirm: () => void; + isLoading: boolean; +} + +const ACTION_CONFIG: Record string; + destructive: boolean; +}> = { + execute: { + title: 'Execute Tasks', + description: (count) => `This will execute ${count} task(s) using the selected strategy.`, + destructive: false, + }, + stop: { + title: 'Stop Tasks', + description: (count) => `This will stop ${count} running task(s). They will need to be re-executed.`, + destructive: true, + }, + reset: { + title: 'Reset Tasks', + description: (count) => `This will reset ${count} failed task(s) to READY status for re-execution.`, + destructive: false, + }, +}; + +export function BulkActionConfirmDialog({ + open, + onOpenChange, + actionType, + taskCount, + onConfirm, + isLoading, +}: BulkActionConfirmDialogProps) { + const config = ACTION_CONFIG[actionType]; + + return ( + + + + {config.title} + + {config.description(taskCount)} + + + + Cancel + { + e.preventDefault(); + onConfirm(); + }} + disabled={isLoading} + className={config.destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''} + > + {isLoading && } + Confirm + + + + + ); +} From 409655d730ad8cb8ca44915f7b8fa4b721331400 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:34:51 -0700 Subject: [PATCH 04/11] feat(BatchActionsBar): add Stop/Reset buttons based on selected task statuses - Add selectedTasks, onStopBatch, onResetBatch, isStoppingBatch, isResettingBatch props - Show Execute/Stop/Reset buttons with counts based on task status - Strategy selector only visible when READY tasks are selected - Destructive variant for Stop, outline for Reset - Loading spinners for each action type - 12 new tests covering all states and interactions --- .../components/tasks/BatchActionsBar.test.tsx | 187 ++++++++++++++++++ .../src/components/tasks/BatchActionsBar.tsx | 112 ++++++++--- 2 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 web-ui/__tests__/components/tasks/BatchActionsBar.test.tsx diff --git a/web-ui/__tests__/components/tasks/BatchActionsBar.test.tsx b/web-ui/__tests__/components/tasks/BatchActionsBar.test.tsx new file mode 100644 index 00000000..caf8f685 --- /dev/null +++ b/web-ui/__tests__/components/tasks/BatchActionsBar.test.tsx @@ -0,0 +1,187 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BatchActionsBar } from '@/components/tasks/BatchActionsBar'; +import type { Task } from '@/types'; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: 'Test Task', + description: 'A test task', + status: 'READY', + priority: 1, + depends_on: [], + ...overrides, + }; +} + +const defaultProps = { + selectionMode: true, + onToggleSelectionMode: jest.fn(), + selectedCount: 0, + strategy: 'serial' as const, + onStrategyChange: jest.fn(), + onExecuteBatch: jest.fn(), + onClearSelection: jest.fn(), + isExecuting: false, + selectedTasks: [] as Task[], + onStopBatch: jest.fn(), + onResetBatch: jest.fn(), + isStoppingBatch: false, + isResettingBatch: false, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('BatchActionsBar', () => { + it('shows Batch button when not in selection mode', () => { + render(); + expect(screen.getByRole('button', { name: /batch/i })).toBeInTheDocument(); + }); + + it('shows Cancel button when in selection mode', () => { + render(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('shows Execute button with count when READY tasks are selected', () => { + const selectedTasks = [ + makeTask({ id: 't1', status: 'READY' }), + makeTask({ id: 't2', status: 'READY' }), + ]; + render( + + ); + expect(screen.getByRole('button', { name: /execute 2/i })).toBeInTheDocument(); + }); + + it('shows Stop button with count when IN_PROGRESS tasks are selected', () => { + const selectedTasks = [ + makeTask({ id: 't1', status: 'IN_PROGRESS' }), + makeTask({ id: 't2', status: 'IN_PROGRESS' }), + makeTask({ id: 't3', status: 'READY' }), + ]; + render( + + ); + expect(screen.getByRole('button', { name: /stop 2/i })).toBeInTheDocument(); + }); + + it('shows Reset button with count when FAILED tasks are selected', () => { + const selectedTasks = [ + makeTask({ id: 't1', status: 'FAILED' }), + makeTask({ id: 't2', status: 'FAILED' }), + makeTask({ id: 't3', status: 'FAILED' }), + ]; + render( + + ); + expect(screen.getByRole('button', { name: /reset 3/i })).toBeInTheDocument(); + }); + + it('shows multiple action buttons for mixed selection', () => { + const selectedTasks = [ + makeTask({ id: 't1', status: 'READY' }), + makeTask({ id: 't2', status: 'IN_PROGRESS' }), + makeTask({ id: 't3', status: 'FAILED' }), + ]; + render( + + ); + expect(screen.getByRole('button', { name: /execute 1/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /stop 1/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reset 1/i })).toBeInTheDocument(); + }); + + it('calls onStopBatch when Stop button is clicked', async () => { + const user = userEvent.setup(); + const selectedTasks = [makeTask({ id: 't1', status: 'IN_PROGRESS' })]; + render( + + ); + await user.click(screen.getByRole('button', { name: /stop 1/i })); + expect(defaultProps.onStopBatch).toHaveBeenCalledTimes(1); + }); + + it('calls onResetBatch when Reset button is clicked', async () => { + const user = userEvent.setup(); + const selectedTasks = [makeTask({ id: 't1', status: 'FAILED' })]; + render( + + ); + await user.click(screen.getByRole('button', { name: /reset 1/i })); + expect(defaultProps.onResetBatch).toHaveBeenCalledTimes(1); + }); + + it('disables Stop button when isStoppingBatch is true', () => { + const selectedTasks = [makeTask({ id: 't1', status: 'IN_PROGRESS' })]; + render( + + ); + expect(screen.getByRole('button', { name: /stop/i })).toBeDisabled(); + }); + + it('disables Reset button when isResettingBatch is true', () => { + const selectedTasks = [makeTask({ id: 't1', status: 'FAILED' })]; + render( + + ); + expect(screen.getByRole('button', { name: /reset/i })).toBeDisabled(); + }); + + it('shows strategy selector only when READY tasks are selected', () => { + const selectedTasks = [makeTask({ id: 't1', status: 'IN_PROGRESS' })]; + render( + + ); + // Strategy selector should not appear when no READY tasks + expect(screen.queryByText('Serial')).not.toBeInTheDocument(); + }); + + it('hides action buttons when no tasks are selected', () => { + render(); + expect(screen.queryByRole('button', { name: /execute/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/components/tasks/BatchActionsBar.tsx b/web-ui/src/components/tasks/BatchActionsBar.tsx index ac26ab9b..92419915 100644 --- a/web-ui/src/components/tasks/BatchActionsBar.tsx +++ b/web-ui/src/components/tasks/BatchActionsBar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CheckListIcon, PlayCircleIcon, Loading03Icon } from '@hugeicons/react'; +import { CheckListIcon, PlayCircleIcon, Loading03Icon, Cancel01Icon, ArrowTurnBackwardIcon } from '@hugeicons/react'; import { Button } from '@/components/ui/button'; import { Select, @@ -9,7 +9,7 @@ import { SelectContent, SelectItem, } from '@/components/ui/select'; -import type { BatchStrategy } from '@/types'; +import type { BatchStrategy, Task } from '@/types'; interface BatchActionsBarProps { selectionMode: boolean; @@ -20,6 +20,11 @@ interface BatchActionsBarProps { onExecuteBatch: () => void; onClearSelection: () => void; isExecuting: boolean; + selectedTasks?: Task[]; + onStopBatch?: () => void; + onResetBatch?: () => void; + isStoppingBatch?: boolean; + isResettingBatch?: boolean; } export function BatchActionsBar({ @@ -31,7 +36,16 @@ export function BatchActionsBar({ onExecuteBatch, onClearSelection, isExecuting, + selectedTasks = [], + onStopBatch, + onResetBatch, + isStoppingBatch = false, + isResettingBatch = false, }: BatchActionsBarProps) { + const readyCount = selectedTasks.filter((t) => t.status === 'READY').length; + const inProgressCount = selectedTasks.filter((t) => t.status === 'IN_PROGRESS').length; + const failedCount = selectedTasks.filter((t) => t.status === 'FAILED').length; + return (
{/* Toggle selection mode */} @@ -52,33 +66,75 @@ export function BatchActionsBar({ {selectedCount} selected - + {/* Strategy selector — only when READY tasks are selected */} + {readyCount > 0 && ( + + )} + + {/* Execute button — READY tasks */} + {readyCount > 0 && ( + + )} - + {/* Stop button — IN_PROGRESS tasks */} + {inProgressCount > 0 && onStopBatch && ( + + )} + + {/* Reset button — FAILED tasks */} + {failedCount > 0 && onResetBatch && ( + + )} {selectedCount > 0 && (
@@ -266,6 +329,18 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onExecute={handleExecute} onStatusChange={handleStatusChange} /> + + {/* Bulk action confirmation */} + { + if (!open) setConfirmAction(null); + }} + actionType={confirmAction?.type ?? 'execute'} + taskCount={confirmAction?.count ?? 0} + onConfirm={handleConfirmAction} + isLoading={isStoppingBatch || isResettingBatch || isExecuting} + />
); } From 49362e866547e3ffbdfed11a02a241556da44980 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:38:35 -0700 Subject: [PATCH 06/11] feat(TaskColumn): add column-level Select All checkbox - Add select-all Checkbox to column header in selection mode - Support checked, unchecked, and indeterminate states - Add onSelectAll/onDeselectAll props through TaskColumn -> TaskBoardContent -> TaskBoardView - Implement handleSelectAll/handleDeselectAll in TaskBoardView - 8 new tests for select-all behavior and edge cases --- .../components/tasks/TaskColumn.test.tsx | 154 ++++++++++++++++++ .../src/components/tasks/TaskBoardContent.tsx | 6 + web-ui/src/components/tasks/TaskBoardView.tsx | 18 ++ web-ui/src/components/tasks/TaskColumn.tsx | 31 +++- 4 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 web-ui/__tests__/components/tasks/TaskColumn.test.tsx diff --git a/web-ui/__tests__/components/tasks/TaskColumn.test.tsx b/web-ui/__tests__/components/tasks/TaskColumn.test.tsx new file mode 100644 index 00000000..e6e02556 --- /dev/null +++ b/web-ui/__tests__/components/tasks/TaskColumn.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TaskColumn } from '@/components/tasks/TaskColumn'; +import type { Task } from '@/types'; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: 'Test Task', + description: 'A test task', + status: 'READY', + priority: 1, + depends_on: [], + ...overrides, + }; +} + +const defaultHandlers = { + onTaskClick: jest.fn(), + onToggleSelect: jest.fn(), + onExecute: jest.fn(), + onMarkReady: jest.fn(), + onStop: jest.fn(), + onReset: jest.fn(), + onSelectAll: jest.fn(), + onDeselectAll: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('TaskColumn - Select All', () => { + const tasks = [ + makeTask({ id: 't1', title: 'Task 1', status: 'READY' }), + makeTask({ id: 't2', title: 'Task 2', status: 'READY' }), + makeTask({ id: 't3', title: 'Task 3', status: 'READY' }), + ]; + + it('does not show select-all checkbox when not in selection mode', () => { + render( + + ); + // Only column header and task cards - no checkbox in header + expect(screen.queryByRole('checkbox', { name: /select all/i })).not.toBeInTheDocument(); + }); + + it('shows unchecked select-all checkbox when no tasks are selected', () => { + render( + + ); + const selectAll = screen.getByRole('checkbox', { name: /select all ready/i }); + expect(selectAll).toBeInTheDocument(); + expect(selectAll).not.toBeChecked(); + }); + + it('shows checked select-all checkbox when all tasks are selected', () => { + render( + + ); + const selectAll = screen.getByRole('checkbox', { name: /select all ready/i }); + expect(selectAll).toBeChecked(); + }); + + it('shows indeterminate select-all checkbox when some tasks are selected', () => { + render( + + ); + const selectAll = screen.getByRole('checkbox', { name: /select all ready/i }); + expect(selectAll).toHaveAttribute('data-state', 'indeterminate'); + }); + + it('calls onSelectAll with task IDs when clicking unchecked select-all', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('checkbox', { name: /select all ready/i })); + expect(defaultHandlers.onSelectAll).toHaveBeenCalledWith(['t1', 't2', 't3']); + }); + + it('calls onDeselectAll with task IDs when clicking checked select-all', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('checkbox', { name: /select all ready/i })); + expect(defaultHandlers.onDeselectAll).toHaveBeenCalledWith(['t1', 't2', 't3']); + }); + + it('calls onSelectAll when clicking indeterminate select-all (selects remaining)', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('checkbox', { name: /select all ready/i })); + expect(defaultHandlers.onSelectAll).toHaveBeenCalledWith(['t1', 't2', 't3']); + }); + + it('does not show select-all checkbox when column is empty', () => { + render( + + ); + expect(screen.queryByRole('checkbox', { name: /select all/i })).not.toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/components/tasks/TaskBoardContent.tsx b/web-ui/src/components/tasks/TaskBoardContent.tsx index 066c8a60..57a3407a 100644 --- a/web-ui/src/components/tasks/TaskBoardContent.tsx +++ b/web-ui/src/components/tasks/TaskBoardContent.tsx @@ -24,6 +24,8 @@ interface TaskBoardContentProps { onMarkReady: (taskId: string) => void; onStop?: (taskId: string) => void; onReset?: (taskId: string) => void; + onSelectAll?: (taskIds: string[]) => void; + onDeselectAll?: (taskIds: string[]) => void; } export function TaskBoardContent({ @@ -36,6 +38,8 @@ export function TaskBoardContent({ onMarkReady, onStop, onReset, + onSelectAll, + onDeselectAll, }: TaskBoardContentProps) { /** Group flat task array into per-status buckets. */ const tasksByStatus = useMemo(() => { @@ -66,6 +70,8 @@ export function TaskBoardContent({ onMarkReady={onMarkReady} onStop={onStop} onReset={onReset} + onSelectAll={onSelectAll} + onDeselectAll={onDeselectAll} /> ))} diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index bc193b4b..08102d5d 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -102,6 +102,22 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { setSelectedTaskIds(new Set()); }, []); + const handleSelectAll = useCallback((taskIds: string[]) => { + setSelectedTaskIds((prev) => { + const next = new Set(prev); + for (const id of taskIds) next.add(id); + return next; + }); + }, []); + + const handleDeselectAll = useCallback((taskIds: string[]) => { + setSelectedTaskIds((prev) => { + const next = new Set(prev); + for (const id of taskIds) next.delete(id); + return next; + }); + }, []); + const handleTaskClick = useCallback((taskId: string) => { setDetailTaskId(taskId); }, []); @@ -318,6 +334,8 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onMarkReady={handleMarkReady} onStop={handleStop} onReset={handleReset} + onSelectAll={handleSelectAll} + onDeselectAll={handleDeselectAll} /> {/* Task detail modal */} diff --git a/web-ui/src/components/tasks/TaskColumn.tsx b/web-ui/src/components/tasks/TaskColumn.tsx index b1ae0dda..b81259e8 100644 --- a/web-ui/src/components/tasks/TaskColumn.tsx +++ b/web-ui/src/components/tasks/TaskColumn.tsx @@ -1,6 +1,7 @@ 'use client'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import { TaskCard } from './TaskCard'; import type { Task, TaskStatus } from '@/types'; @@ -26,6 +27,8 @@ interface TaskColumnProps { onMarkReady: (taskId: string) => void; onStop?: (taskId: string) => void; onReset?: (taskId: string) => void; + onSelectAll?: (taskIds: string[]) => void; + onDeselectAll?: (taskIds: string[]) => void; } export function TaskColumn({ @@ -39,14 +42,36 @@ export function TaskColumn({ onMarkReady, onStop, onReset, + onSelectAll, + onDeselectAll, }: TaskColumnProps) { + const taskIds = tasks.map((t) => t.id); + const selectedCount = tasks.filter((t) => selectedTaskIds.has(t.id)).length; + const allSelected = tasks.length > 0 && selectedCount === tasks.length; + const someSelected = selectedCount > 0 && selectedCount < tasks.length; + return (
{/* Column header */}
-

- {STATUS_LABEL[status]} -

+
+ {selectionMode && tasks.length > 0 && ( + { + if (allSelected) { + onDeselectAll?.(taskIds); + } else { + onSelectAll?.(taskIds); + } + }} + aria-label={`Select all ${STATUS_LABEL[status]} tasks`} + /> + )} +

+ {STATUS_LABEL[status]} +

+
{tasks.length} From 2b040ff1ad63e42738e9d7b2f4c0fb534f71a205 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 21:40:54 -0700 Subject: [PATCH 07/11] feat(TaskCard): add per-task loading states with spinner feedback - Add isLoading prop to TaskCard, show Loading03Icon spinner instead of action buttons - Add loadingTaskIds Set state to TaskBoardView for per-task tracking - Wire loadingTaskIds through TaskBoardContent -> TaskColumn -> TaskCard - handleStop/handleReset add task to loadingTaskIds during API call - 4 new tests for loading spinner behavior across task statuses --- .../components/tasks/TaskCard.test.tsx | 26 ++++ .../src/components/tasks/TaskBoardContent.tsx | 3 + web-ui/src/components/tasks/TaskBoardView.tsx | 18 +++ web-ui/src/components/tasks/TaskCard.tsx | 120 ++++++++++-------- web-ui/src/components/tasks/TaskColumn.tsx | 3 + 5 files changed, 114 insertions(+), 56 deletions(-) diff --git a/web-ui/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/__tests__/components/tasks/TaskCard.test.tsx index a16e3c56..b767b908 100644 --- a/web-ui/__tests__/components/tasks/TaskCard.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskCard.test.tsx @@ -201,4 +201,30 @@ describe('TaskCard', () => { const stopBtn = screen.getByRole('button', { name: /stop/i }); expect(stopBtn).toHaveClass('text-destructive'); }); + + // ─── Loading state tests ────────────────────────────────────────── + + it('shows loading spinner instead of action button when isLoading', () => { + renderCard({ status: 'IN_PROGRESS' }, { isLoading: true }); + // Should show spinner, not the Stop button + expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + }); + + it('shows action buttons when isLoading is false', () => { + renderCard({ status: 'IN_PROGRESS' }, { isLoading: false }); + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + }); + + it('shows loading spinner for READY task when isLoading', () => { + renderCard({ status: 'READY' }, { isLoading: true }); + expect(screen.queryByRole('button', { name: /execute/i })).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + }); + + it('shows loading spinner for FAILED task when isLoading', () => { + renderCard({ status: 'FAILED' }, { isLoading: true }); + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + }); }); diff --git a/web-ui/src/components/tasks/TaskBoardContent.tsx b/web-ui/src/components/tasks/TaskBoardContent.tsx index 57a3407a..666a6f7b 100644 --- a/web-ui/src/components/tasks/TaskBoardContent.tsx +++ b/web-ui/src/components/tasks/TaskBoardContent.tsx @@ -26,6 +26,7 @@ interface TaskBoardContentProps { onReset?: (taskId: string) => void; onSelectAll?: (taskIds: string[]) => void; onDeselectAll?: (taskIds: string[]) => void; + loadingTaskIds?: Set; } export function TaskBoardContent({ @@ -40,6 +41,7 @@ export function TaskBoardContent({ onReset, onSelectAll, onDeselectAll, + loadingTaskIds, }: TaskBoardContentProps) { /** Group flat task array into per-status buckets. */ const tasksByStatus = useMemo(() => { @@ -72,6 +74,7 @@ export function TaskBoardContent({ onReset={onReset} onSelectAll={onSelectAll} onDeselectAll={onDeselectAll} + loadingTaskIds={loadingTaskIds} /> ))}
diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 08102d5d..537b85c3 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -51,6 +51,9 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { // ─── Error state for actions ─────────────────────────────────── const [actionError, setActionError] = useState(null); + // ─── Per-task loading state ────────────────────────────────────── + const [loadingTaskIds, setLoadingTaskIds] = useState>(new Set()); + // ─── Filtered tasks ──────────────────────────────────────────── const filteredTasks = useMemo(() => { if (!data?.tasks) return []; @@ -158,12 +161,19 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { const handleStop = useCallback( async (taskId: string) => { setActionError(null); + setLoadingTaskIds((prev) => new Set(prev).add(taskId)); try { await tasksApi.stopExecution(workspacePath, taskId); await mutate(); } catch (err) { const apiErr = err as ApiError; setActionError(apiErr.detail || 'Failed to stop task'); + } finally { + setLoadingTaskIds((prev) => { + const next = new Set(prev); + next.delete(taskId); + return next; + }); } }, [workspacePath, mutate] @@ -172,12 +182,19 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { const handleReset = useCallback( async (taskId: string) => { setActionError(null); + setLoadingTaskIds((prev) => new Set(prev).add(taskId)); try { await tasksApi.updateStatus(workspacePath, taskId, 'READY'); await mutate(); } catch (err) { const apiErr = err as ApiError; setActionError(apiErr.detail || 'Failed to reset task'); + } finally { + setLoadingTaskIds((prev) => { + const next = new Set(prev); + next.delete(taskId); + return next; + }); } }, [workspacePath, mutate] @@ -336,6 +353,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onReset={handleReset} onSelectAll={handleSelectAll} onDeselectAll={handleDeselectAll} + loadingTaskIds={loadingTaskIds} /> {/* Task detail modal */} diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index 50eeda49..f3f324bb 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Cancel01Icon, ArrowTurnBackwardIcon } from '@hugeicons/react'; +import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Cancel01Icon, ArrowTurnBackwardIcon, Loading03Icon } from '@hugeicons/react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -39,6 +39,7 @@ interface TaskCardProps { onMarkReady: (taskId: string) => void; onStop?: (taskId: string) => void; onReset?: (taskId: string) => void; + isLoading?: boolean; } export function TaskCard({ @@ -51,6 +52,7 @@ export function TaskCard({ onMarkReady, onStop, onReset, + isLoading = false, }: TaskCardProps) { return ( - {task.status === 'READY' && ( - - )} - {task.status === 'BACKLOG' && ( - - )} - {task.status === 'IN_PROGRESS' && onStop && ( - - )} - {task.status === 'FAILED' && onReset && ( - + {isLoading ? ( + + ) : ( + <> + {task.status === 'READY' && ( + + )} + {task.status === 'BACKLOG' && ( + + )} + {task.status === 'IN_PROGRESS' && onStop && ( + + )} + {task.status === 'FAILED' && onReset && ( + + )} + )}
)} diff --git a/web-ui/src/components/tasks/TaskColumn.tsx b/web-ui/src/components/tasks/TaskColumn.tsx index b81259e8..15f5f8d8 100644 --- a/web-ui/src/components/tasks/TaskColumn.tsx +++ b/web-ui/src/components/tasks/TaskColumn.tsx @@ -29,6 +29,7 @@ interface TaskColumnProps { onReset?: (taskId: string) => void; onSelectAll?: (taskIds: string[]) => void; onDeselectAll?: (taskIds: string[]) => void; + loadingTaskIds?: Set; } export function TaskColumn({ @@ -44,6 +45,7 @@ export function TaskColumn({ onReset, onSelectAll, onDeselectAll, + loadingTaskIds = new Set(), }: TaskColumnProps) { const taskIds = tasks.map((t) => t.id); const selectedCount = tasks.filter((t) => selectedTaskIds.has(t.id)).length; @@ -96,6 +98,7 @@ export function TaskColumn({ onMarkReady={onMarkReady} onStop={onStop} onReset={onReset} + isLoading={loadingTaskIds.has(task.id)} /> )) )} From 64971e0a644b0307c99487f786699539b9936ae2 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 18 Feb 2026 23:45:21 -0700 Subject: [PATCH 08/11] fix: address code review feedback for accessibility and race condition - Add role="status" and aria-label to loading spinner in TaskCard - Freeze task IDs at dialog-open time to prevent count desync from SWR revalidation - Add role="alert" and dismiss button to error banner in TaskBoardView - Use frozen taskIds in handleConfirmAction instead of re-filtering selectedTasks --- web-ui/src/components/tasks/TaskBoardView.tsx | 29 ++++++++++++------- web-ui/src/components/tasks/TaskCard.tsx | 4 ++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 537b85c3..3d59ecc0 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -8,6 +8,7 @@ import { TaskDetailModal } from './TaskDetailModal'; import { TaskFilters } from './TaskFilters'; import { BatchActionsBar } from './BatchActionsBar'; import { BulkActionConfirmDialog, type BulkActionType } from './BulkActionConfirmDialog'; +import { Cancel01Icon } from '@hugeicons/react'; import { tasksApi } from '@/lib/api'; import type { TaskStatus, @@ -43,6 +44,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { const [confirmAction, setConfirmAction] = useState<{ type: BulkActionType; count: number; + taskIds: string[]; } | null>(null); // ─── Detail modal state ──────────────────────────────────────── @@ -221,13 +223,13 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { }, [workspacePath, selectedTaskIds, batchStrategy, router]); const handleStopBatch = useCallback(() => { - const inProgressCount = selectedTasks.filter((t) => t.status === 'IN_PROGRESS').length; - setConfirmAction({ type: 'stop', count: inProgressCount }); + const inProgressTasks = selectedTasks.filter((t) => t.status === 'IN_PROGRESS'); + setConfirmAction({ type: 'stop', count: inProgressTasks.length, taskIds: inProgressTasks.map((t) => t.id) }); }, [selectedTasks]); const handleResetBatch = useCallback(() => { - const failedCount = selectedTasks.filter((t) => t.status === 'FAILED').length; - setConfirmAction({ type: 'reset', count: failedCount }); + const failedTasks = selectedTasks.filter((t) => t.status === 'FAILED'); + setConfirmAction({ type: 'reset', count: failedTasks.length, taskIds: failedTasks.map((t) => t.id) }); }, [selectedTasks]); const handleConfirmAction = useCallback(async () => { @@ -236,9 +238,8 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { if (confirmAction.type === 'stop') { setIsStoppingBatch(true); - const inProgressTasks = selectedTasks.filter((t) => t.status === 'IN_PROGRESS'); const results = await Promise.allSettled( - inProgressTasks.map((t) => tasksApi.stopExecution(workspacePath, t.id)) + confirmAction.taskIds.map((id) => tasksApi.stopExecution(workspacePath, id)) ); const failures = results.filter((r) => r.status === 'rejected'); if (failures.length > 0) { @@ -247,9 +248,8 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { setIsStoppingBatch(false); } else if (confirmAction.type === 'reset') { setIsResettingBatch(true); - const failedTasks = selectedTasks.filter((t) => t.status === 'FAILED'); const results = await Promise.allSettled( - failedTasks.map((t) => tasksApi.updateStatus(workspacePath, t.id, 'READY')) + confirmAction.taskIds.map((id) => tasksApi.updateStatus(workspacePath, id, 'READY')) ); const failures = results.filter((r) => r.status === 'rejected'); if (failures.length > 0) { @@ -263,7 +263,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { setConfirmAction(null); handleClearSelection(); await mutate(); - }, [confirmAction, selectedTasks, workspacePath, mutate, handleClearSelection, handleExecuteBatch]); + }, [confirmAction, workspacePath, mutate, handleClearSelection, handleExecuteBatch]); const handleStatusChange = useCallback(() => { mutate(); @@ -335,8 +335,15 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { {/* Action error banner */} {actionError && ( -
- {actionError} +
+ {actionError} +
)} diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index f3f324bb..ea239307 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -108,7 +108,9 @@ export function TaskCard({ {(task.status === 'READY' || task.status === 'BACKLOG' || task.status === 'IN_PROGRESS' || task.status === 'FAILED') && (
{isLoading ? ( - + + + ) : ( <> {task.status === 'READY' && ( From 5bcc5f574080dabd8578eda6ecd7e50f19e47fd6 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Feb 2026 00:12:39 -0700 Subject: [PATCH 09/11] fix: address PR review feedback from CodeRabbit and Claude - Wrap handleConfirmAction in try/finally so loading states and dialog cleanup always execute, even if mutate() throws - Move loading flag reset into finally block to keep spinners visible until SWR refresh completes (prevents stale data flash) - Remove unreachable 'execute' branch from handleConfirmAction - Derive selectedTasks from full task list instead of filteredTasks so bulk actions include all selected tasks regardless of active filters - Replace getByTestId('icon-Loading03Icon') with getByRole('status') to test actual accessibility behavior, not jest mock internals - Remove unused checkboxes variable in TaskBoardView test - Add JSDoc on optional onStop/onReset props in TaskCard --- .../components/tasks/TaskBoardView.test.tsx | 2 - .../components/tasks/TaskCard.test.tsx | 6 +-- web-ui/src/components/tasks/TaskBoardView.tsx | 54 ++++++++++--------- web-ui/src/components/tasks/TaskCard.tsx | 2 + 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx index 614a04ce..126244a8 100644 --- a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx @@ -296,8 +296,6 @@ describe('TaskBoardView', () => { await user.click(screen.getByRole('button', { name: /batch/i })); // Select the IN_PROGRESS task (t3 "Build API") - const checkboxes = screen.getAllByRole('checkbox'); - // Find the checkbox for "Build API" - it's the one with aria-label containing "Build API" const buildApiCheckbox = screen.getByRole('checkbox', { name: /select build api/i }); await user.click(buildApiCheckbox); diff --git a/web-ui/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/__tests__/components/tasks/TaskCard.test.tsx index b767b908..8b777b65 100644 --- a/web-ui/__tests__/components/tasks/TaskCard.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskCard.test.tsx @@ -208,7 +208,7 @@ describe('TaskCard', () => { renderCard({ status: 'IN_PROGRESS' }, { isLoading: true }); // Should show spinner, not the Stop button expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument(); - expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); }); it('shows action buttons when isLoading is false', () => { @@ -219,12 +219,12 @@ describe('TaskCard', () => { it('shows loading spinner for READY task when isLoading', () => { renderCard({ status: 'READY' }, { isLoading: true }); expect(screen.queryByRole('button', { name: /execute/i })).not.toBeInTheDocument(); - expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); }); it('shows loading spinner for FAILED task when isLoading', () => { renderCard({ status: 'FAILED' }, { isLoading: true }); expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); - expect(screen.getByTestId('icon-Loading03Icon')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); }); }); diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 3d59ecc0..0b38dddc 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -78,9 +78,11 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { }, [data?.tasks, statusFilter, searchQuery]); // ─── Selected tasks (for batch actions) ─────────────────────── + // Derive from full task list (not filteredTasks) so bulk actions + // include all selected tasks even when filters hide some of them. const selectedTasks = useMemo( - () => filteredTasks.filter((t) => selectedTaskIds.has(t.id)), - [filteredTasks, selectedTaskIds] + () => (data?.tasks ?? []).filter((t) => selectedTaskIds.has(t.id)), + [data?.tasks, selectedTaskIds] ); // ─── Handlers ────────────────────────────────────────────────── @@ -236,34 +238,34 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { if (!confirmAction) return; setActionError(null); - if (confirmAction.type === 'stop') { - setIsStoppingBatch(true); - const results = await Promise.allSettled( - confirmAction.taskIds.map((id) => tasksApi.stopExecution(workspacePath, id)) - ); - const failures = results.filter((r) => r.status === 'rejected'); - if (failures.length > 0) { - setActionError(`Failed to stop ${failures.length} task(s)`); + try { + if (confirmAction.type === 'stop') { + setIsStoppingBatch(true); + const results = await Promise.allSettled( + confirmAction.taskIds.map((id) => tasksApi.stopExecution(workspacePath, id)) + ); + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + setActionError(`Failed to stop ${failures.length} task(s)`); + } + } else if (confirmAction.type === 'reset') { + setIsResettingBatch(true); + const results = await Promise.allSettled( + confirmAction.taskIds.map((id) => tasksApi.updateStatus(workspacePath, id, 'READY')) + ); + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + setActionError(`Failed to reset ${failures.length} task(s)`); + } } + } finally { setIsStoppingBatch(false); - } else if (confirmAction.type === 'reset') { - setIsResettingBatch(true); - const results = await Promise.allSettled( - confirmAction.taskIds.map((id) => tasksApi.updateStatus(workspacePath, id, 'READY')) - ); - const failures = results.filter((r) => r.status === 'rejected'); - if (failures.length > 0) { - setActionError(`Failed to reset ${failures.length} task(s)`); - } setIsResettingBatch(false); - } else if (confirmAction.type === 'execute') { - await handleExecuteBatch(); + setConfirmAction(null); + handleClearSelection(); + await mutate(); } - - setConfirmAction(null); - handleClearSelection(); - await mutate(); - }, [confirmAction, workspacePath, mutate, handleClearSelection, handleExecuteBatch]); + }, [confirmAction, workspacePath, mutate, handleClearSelection]); const handleStatusChange = useCallback(() => { mutate(); diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index ea239307..9bd017b6 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -37,7 +37,9 @@ interface TaskCardProps { onClick: (taskId: string) => void; onExecute: (taskId: string) => void; onMarkReady: (taskId: string) => void; + /** Optional — when omitted, IN_PROGRESS cards silently hide the Stop button. TaskBoardView always provides this. */ onStop?: (taskId: string) => void; + /** Optional — when omitted, FAILED cards silently hide the Reset button. TaskBoardView always provides this. */ onReset?: (taskId: string) => void; isLoading?: boolean; } From 68606fec8a0fa2a22516af6c6bae92b9d399a967 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Feb 2026 01:16:30 -0700 Subject: [PATCH 10/11] fix: narrow BulkActionType, add partial failure test, code review report - Remove 'execute' from BulkActionType (unreachable after earlier fix) - Remove dead execute config from ACTION_CONFIG - Add test for batch stop partial failure error message - Update BulkActionConfirmDialog tests for narrowed type - Add code review report documenting all resolved issues --- ...26-02-19-task-board-bulk-actions-review.md | 127 ++++++++++++++++++ .../tasks/BulkActionConfirmDialog.test.tsx | 10 +- .../components/tasks/TaskBoardView.test.tsx | 25 ++++ .../tasks/BulkActionConfirmDialog.tsx | 7 +- web-ui/src/components/tasks/TaskBoardView.tsx | 2 +- 5 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 docs/code-review/2026-02-19-task-board-bulk-actions-review.md diff --git a/docs/code-review/2026-02-19-task-board-bulk-actions-review.md b/docs/code-review/2026-02-19-task-board-bulk-actions-review.md new file mode 100644 index 00000000..f0f501c1 --- /dev/null +++ b/docs/code-review/2026-02-19-task-board-bulk-actions-review.md @@ -0,0 +1,127 @@ +# Code Review Report: Task Board Bulk Stop, Reset & State Management + +**Date:** 2026-02-19 +**Reviewer:** Code Review Agent +**Component:** Task Board (web-ui/src/components/tasks/) +**PR:** #389 (feature/issue-340-task-board-bulk-actions) +**Files Reviewed:** 6 production files, 5 test files +**Ready for Production:** Yes + +## Executive Summary + +Well-structured feature addition that extends the Task Board with single-task and bulk stop/reset actions. Three rounds of review (CodeRabbit, Claude bot, manual) identified and resolved all issues. Final code is clean, accessible, and well-tested. + +**Critical Issues:** 0 +**Major Issues:** 0 (all resolved) +**Minor Issues:** 2 (accepted trade-offs, documented below) +**Positive Findings:** 8 + +--- + +## Review Context + +**Code Type:** UI Components (React/Next.js) +**Risk Level:** Low-Medium (UI-only, calls existing backend APIs) +**Business Constraints:** Standard feature, no performance-critical requirements + +### Review Focus Areas + +- ✅ A04 Insecure Design — Workflow race conditions, state consistency +- ✅ Reliability — Error handling, async cleanup, loading states +- ✅ Maintainability — Type safety, test coverage, code patterns +- ❌ A01 Access Control — Skipped, frontend doesn't enforce auth +- ❌ A03 Injection — Skipped, no raw user input to backend +- ❌ LLM/ML Security — Not applicable + +--- + +## Priority 1 Issues - Critical + +None. + +--- + +## Priority 2 Issues - Major (All Resolved) + +### 1. Loading state cleared before SWR refresh (RESOLVED) +**Location:** `TaskBoardView.tsx:237-268` +**Fix:** Wrapped `handleConfirmAction` in try/finally, loading flags reset in finally block. + +### 2. Missing try/finally could leave dialog stuck (RESOLVED) +**Location:** `TaskBoardView.tsx:237-268` +**Fix:** Same try/finally restructure. + +### 3. selectedTasks scope inconsistency with filters (RESOLVED) +**Location:** `TaskBoardView.tsx:83-86` +**Fix:** `selectedTasks` now derives from `data?.tasks` instead of `filteredTasks`. + +### 4. Tests relied on mock-specific data-testid (RESOLVED) +**Location:** `TaskCard.test.tsx` +**Fix:** Replaced `getByTestId('icon-Loading03Icon')` with `getByRole('status', { name: /loading/i })`. + +### 5. SWR revalidation race condition on confirmation count (RESOLVED) +**Location:** `TaskBoardView.tsx:227-235` +**Fix:** Task IDs frozen into `confirmAction` state at dialog-open time. + +### 6. BulkActionType included unreachable 'execute' variant (RESOLVED) +**Location:** `BulkActionConfirmDialog.tsx:15` +**Fix:** Narrowed type to `'stop' | 'reset'`, removed dead config entry. + +### 7. Missing test for partial failure in batch operations (RESOLVED) +**Location:** `TaskBoardView.test.tsx` +**Fix:** Added test that mocks `stopExecution` rejection and verifies error banner. + +--- + +## Priority 3 Issues - Minor + +### 1. Unsafe error cast `err as ApiError` +**Location:** `TaskBoardView.tsx:173, 194` +**Category:** Type Safety + +The `err as ApiError` pattern doesn't guarantee `.detail` exists on non-API errors (e.g., TypeError). However, the fallback string always fires for non-API errors since `undefined || 'fallback'` resolves correctly. This is also the pre-existing pattern used throughout the codebase. + +**Decision:** Accepted. Functionally correct. Fixing only in new code would be inconsistent. + +### 2. handleClearSelection fires on partial failure +**Location:** `TaskBoardView.tsx:265` +**Category:** UX + +When 3 of 5 bulk-stop calls fail, selection is cleared, losing visibility into which tasks were affected. The error message does report failure count. + +**Decision:** Accepted trade-off for v1. Retaining selection on partial failure would require tracking per-task success/failure state. + +--- + +## Positive Findings + +1. **Race condition mitigation**: Freezing `taskIds` into `confirmAction` at dialog-open time isolates batch from SWR revalidation mid-flight. +2. **Promise.allSettled**: Correct choice for bulk ops — partial failures handled gracefully. +3. **AlertDialog over Dialog**: Proper Radix primitive for destructive confirmations (focus trap, explicit dismiss). +4. **Accessibility**: `role="alert"` on error banner, `role="status"` on spinner, dismiss button, keyboard nav preserved, ARIA labels on all interactive elements. +5. **e.preventDefault() on AlertDialogAction**: Prevents auto-close before async confirm completes. +6. **Per-task loading state**: `loadingTaskIds` Set prevents double-clicks and gives clear visual feedback. +7. **useCallback/useMemo throughout**: Handlers are stable references, derived state is memoized. +8. **Comprehensive test coverage**: 302 tests covering happy paths, error paths, loading states, accessibility, and now partial failures. + +--- + +## Action Items Summary + +### Immediate (Before Merge) +All resolved. + +### Short-term (Backlog) +1. Consider retaining selection on partial bulk-action failure +2. Consider a shared `extractErrorDetail(err)` utility for safer error extraction + +### Long-term (Backlog) +1. Shift-click range selection for task checkboxes (deferred from issue #340) + +--- + +## Conclusion + +All critical and major issues from three review rounds have been resolved. The implementation follows existing codebase patterns, uses proper Shadcn/UI primitives, maintains accessibility standards, and has thorough test coverage including edge cases. Ready to merge. + +**Recommendation:** Deploy diff --git a/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx b/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx index 5f863c64..3f478d31 100644 --- a/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx +++ b/web-ui/__tests__/components/tasks/BulkActionConfirmDialog.test.tsx @@ -5,7 +5,7 @@ import { BulkActionConfirmDialog } from '@/components/tasks/BulkActionConfirmDia const defaultProps = { open: true, onOpenChange: jest.fn(), - actionType: 'execute' as const, + actionType: 'stop' as const, taskCount: 3, onConfirm: jest.fn(), isLoading: false, @@ -16,12 +16,6 @@ beforeEach(() => { }); describe('BulkActionConfirmDialog', () => { - it('renders execute confirmation with correct title and description', () => { - render(); - expect(screen.getByText('Execute Tasks')).toBeInTheDocument(); - expect(screen.getByText(/execute 5 task\(s\)/i)).toBeInTheDocument(); - }); - it('renders stop confirmation with correct title and description', () => { render(); expect(screen.getByText('Stop Tasks')).toBeInTheDocument(); @@ -56,7 +50,7 @@ describe('BulkActionConfirmDialog', () => { it('does not render when open is false', () => { render(); - expect(screen.queryByText('Execute Tasks')).not.toBeInTheDocument(); + expect(screen.queryByText('Stop Tasks')).not.toBeInTheDocument(); }); it('shows destructive styling for stop action confirm button', () => { diff --git a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx index 126244a8..b228548c 100644 --- a/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskBoardView.test.tsx @@ -388,4 +388,29 @@ describe('TaskBoardView', () => { }); expect(mockMutate).toHaveBeenCalled(); }); + + it('shows error message when batch stop partially fails', async () => { + const { tasksApi } = require('@/lib/api'); + tasksApi.stopExecution.mockRejectedValue({ detail: 'Task not running' }); + mockMutate.mockResolvedValue(undefined); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + act(() => { jest.advanceTimersByTime(350); }); + + // Enter selection mode and select IN_PROGRESS task + await user.click(screen.getByRole('button', { name: /batch/i })); + const buildApiCheckbox = screen.getByRole('checkbox', { name: /select build api/i }); + await user.click(buildApiCheckbox); + + // Click batch Stop button to open dialog + await user.click(screen.getByRole('button', { name: /stop 1/i })); + + // Click Confirm in dialog + await user.click(screen.getByRole('button', { name: /confirm/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/failed to stop 1 task/i); + }); + }); }); diff --git a/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx b/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx index f7d5c0d2..16d4783c 100644 --- a/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx +++ b/web-ui/src/components/tasks/BulkActionConfirmDialog.tsx @@ -12,7 +12,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; -export type BulkActionType = 'execute' | 'stop' | 'reset'; +export type BulkActionType = 'stop' | 'reset'; interface BulkActionConfirmDialogProps { open: boolean; @@ -28,11 +28,6 @@ const ACTION_CONFIG: Record string; destructive: boolean; }> = { - execute: { - title: 'Execute Tasks', - description: (count) => `This will execute ${count} task(s) using the selected strategy.`, - destructive: false, - }, stop: { title: 'Stop Tasks', description: (count) => `This will stop ${count} running task(s). They will need to be re-executed.`, diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 0b38dddc..bca56632 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -381,7 +381,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onOpenChange={(open) => { if (!open) setConfirmAction(null); }} - actionType={confirmAction?.type ?? 'execute'} + actionType={confirmAction?.type ?? 'stop'} taskCount={confirmAction?.count ?? 0} onConfirm={handleConfirmAction} isLoading={isStoppingBatch || isResettingBatch || isExecuting} From a2fe539099db1935930853de8aef7da26004baf8 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Feb 2026 01:40:50 -0700 Subject: [PATCH 11/11] feat(TaskDetailModal): add View Execution button for IN_PROGRESS tasks Acceptance criterion #1 from issue #340 requires clicking an IN_PROGRESS task to navigate to the Execution Monitor. Adds a "View Execution" button in the TaskDetailModal footer that closes the modal and navigates to /execution/{taskId}. --- web-ui/__mocks__/@hugeicons/react.js | 1 + .../components/tasks/TaskDetailModal.test.tsx | 20 +++++++++++++++++++ .../src/components/tasks/TaskDetailModal.tsx | 15 ++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 848216f0..10c8255e 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -40,6 +40,7 @@ module.exports = { // Task Board components PlayCircleIcon: createIconMock('PlayCircleIcon'), LinkCircleIcon: createIconMock('LinkCircleIcon'), + ViewIcon: createIconMock('ViewIcon'), Search01Icon: createIconMock('Search01Icon'), CheckListIcon: createIconMock('CheckListIcon'), // Execution Monitor components diff --git a/web-ui/__tests__/components/tasks/TaskDetailModal.test.tsx b/web-ui/__tests__/components/tasks/TaskDetailModal.test.tsx index d8002f58..50c790f7 100644 --- a/web-ui/__tests__/components/tasks/TaskDetailModal.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskDetailModal.test.tsx @@ -6,6 +6,11 @@ import type { Task } from '@/types'; // ─── Mocks ────────────────────────────────────────────────────────── +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), prefetch: jest.fn() }), +})); + jest.mock('@/lib/api', () => ({ tasksApi: { getOne: jest.fn(), @@ -144,6 +149,21 @@ describe('TaskDetailModal', () => { expect(screen.queryByRole('button', { name: /mark ready/i })).not.toBeInTheDocument(); }); + it('shows View Execution button for IN_PROGRESS tasks and navigates on click', async () => { + const user = userEvent.setup(); + mockGetOne.mockResolvedValue(makeTask({ status: 'IN_PROGRESS' })); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /view execution/i })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /execute/i })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /view execution/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/execution/task-1'); + }); + it('calls onExecute when Execute button is clicked', async () => { const user = userEvent.setup(); mockGetOne.mockResolvedValue(makeTask({ status: 'READY' })); diff --git a/web-ui/src/components/tasks/TaskDetailModal.tsx b/web-ui/src/components/tasks/TaskDetailModal.tsx index 62669e6e..526159b5 100644 --- a/web-ui/src/components/tasks/TaskDetailModal.tsx +++ b/web-ui/src/components/tasks/TaskDetailModal.tsx @@ -1,12 +1,14 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Loading03Icon, Time01Icon, + ViewIcon, } from '@hugeicons/react'; import { Dialog, @@ -58,6 +60,7 @@ export function TaskDetailModal({ onExecute, onStatusChange, }: TaskDetailModalProps) { + const router = useRouter(); const [task, setTask] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -194,6 +197,18 @@ export function TaskDetailModal({ Mark Ready )} + {task.status === 'IN_PROGRESS' && ( + + )} {task.status === 'READY' && (