From 3b87e24d7be98dd8a195690538df3bfe6cf955fc Mon Sep 17 00:00:00 2001 From: JoshuaRileyDev <59296334+JoshuaRileyDev@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:19:10 +0000 Subject: [PATCH] feat: Queue System v2 with Auto-Promotion and Smart Task Management (#1203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement queue system v2 with auto-promotion - Add Queue column to Kanban board between Planning and In Progress - Implement configurable parallel task limit (default: 3) - Add auto-promotion from Queue to In Progress when capacity becomes available - Add "Add All to Queue" button to move all Planning tasks to Queue - Add Queue Settings modal for configuring max parallel tasks - Replace 'pr_created' status with 'queue' in task types - Add queue-related i18n translations (en/fr) - Add queue column styling to globals.css When parallel task limit is reached, tasks are automatically moved to Queue instead of In Progress. When a task leaves In Progress, the oldest queued task is automatically promoted to fill the available slot (FIFO ordering). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: remove incorrect store selector for updateProjectSettings updateProjectSettings is an exported function, not a store method. Remove the incorrect selector that was causing runtime errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(queue): fix auto-promotion and max parallel tasks settings Fixes three issues with the queue system: 1. Auto-promotion after bulk add: processQueue() is now called after handleQueueAll() to automatically promote queued tasks when bulk-adding tasks from backlog to queue. 2. Auto-promotion on task completion: Added task status change listener mechanism that triggers processQueue() whenever a task leaves in_progress status (e.g., goes to human_review), ensuring slots are filled automatically. 3. Max parallel tasks settings: Fixed settings merge to handle undefined project.settings and added error handling with proper toast notifications. Co-Authored-By: Claude Signed-off-by: Joshua Riley * fix(queue): add queue status mapper and i18n translation keys Fixes blocking issues from pre-PR validation: 1. Add 'queue' case to mapStatusToPlanStatus() in plan-file-utils.ts - Maps 'queue' TaskStatus to 'queued' planStatus for backend compatibility 2. Add i18n translation keys for queue settings modal - English translations: queue.settings.* in en/tasks.json - French translations: queue.settings.* in fr/tasks.json - All user-facing strings now use translation keys 3. Update QueueSettingsModal.tsx to use translation keys - Title, description, labels, validation messages, buttons - Imports 'common' namespace for cancel/save buttons 4. Update KanbanBoard.tsx toast messages to use translation keys - Settings saved/success/error messages Co-Authored-By: Claude Signed-off-by: Joshua Riley * fix(queue): prevent infinite loop when persistTaskStatus fails Fix critical bug where processQueue() would infinite loop if persistTaskStatus() returns { success: false } without throwing. Changes: - processQueue: Check return value of persistTaskStatus and skip failed tasks with error logging - handleQueueAll: Check return value and only count successful moves The bug occurred because: 1. persistTaskStatus can fail without throwing 2. On failure, task status remains unchanged (still in queue) 3. Loop would re-select same task and try again infinitely Fix prevents UI freeze and excessive IPC calls. Co-Authored-By: Claude Signed-off-by: Joshua Riley * fix: resolve merge conflicts - remove pr_created status references The pr_created task status has been removed in favor of 'done' status. This commit fixes all TypeScript errors related to the removed status. Changes: - task-store.ts: Updated TaskOrderState to use 'queue' instead of 'pr_created' - KanbanBoard.tsx: Removed pr_created from getVisualColumn and cleanedOrder - TaskCard.tsx: Updated status checks to use 'done' instead of 'pr_created' - Worktrees.tsx: Updated PR creation to set status to 'done' - TaskDetailModal.tsx: Updated status checks for PR completion display - project-store.ts: Removed pr_created from statusMap - plan-file-utils.ts: Removed pr_created from status conversion - worktree-handlers.ts: Updated PR status persistence to 'done' - task-order.test.ts: Updated test helper to use 'queue' instead of 'pr_created' - task-store.test.ts: Removed pr_created test case - test_auth.py: Updated error message regex to match new authentication text Co-Authored-By: Claude * fix(tests): update auth test regex to match 'No OAuth token found' * fix(queue): address PR review comments for queue system v2 - Fix critical worktree cleanup bypass in drag-drop by using handleStatusChange - Fix empty projectId handling in QueueSettingsModal with conditional render - Fix input silently ignored when cleared in QueueSettingsModal - Add missing queue status label to i18n tasks.json - Guard undefined project.settings before reading maxParallelTasks - Improve processQueue failure handling to prevent infinite loops Co-Authored-By: Claude * fix(queue): prevent race condition in processQueue Add mutex lock using useRef to prevent concurrent processQueue executions that could violate maxParallelTasks limit. This addresses a race condition where multiple processQueue calls triggered by the status change listener could read stale in-progress counts and promote more tasks than allowed. Changes: - Add useRef import for isProcessingQueueRef flag - Add early return if queue processing is already active - Wrap processQueue body in try/finally to ensure lock release Co-Authored-By: Claude * fix(kanban): prevent race condition in queue parallel task limit Fixed race condition where manual drag from queue to in_progress could exceed maxParallelTasks limit during automatic queue promotion. The bypass for queue->in_progress transitions now only applies during active queue processing (when isProcessingQueueRef is true), preventing both auto-promotion and manual drag from succeeding simultaneously. Fixes potential bug identified in KanbanBoard.tsx#L1043-L1058 Co-Authored-By: Claude --------- Signed-off-by: Joshua Riley Co-authored-by: Claude Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- .../main/ipc-handlers/task/plan-file-utils.ts | 4 +- .../ipc-handlers/task/worktree-handlers.ts | 8 +- .../src/renderer/__tests__/task-order.test.ts | 24 +- .../src/renderer/__tests__/task-store.test.ts | 29 -- .../src/renderer/components/KanbanBoard.tsx | 392 ++++++++++++++---- .../components/QueueSettingsModal.tsx | 118 ++++++ .../src/renderer/components/TaskCard.tsx | 8 +- .../src/renderer/components/Worktrees.tsx | 4 +- .../task-detail/TaskDetailModal.tsx | 23 +- .../src/renderer/stores/project-store.ts | 4 +- .../src/renderer/stores/task-store.ts | 74 +++- apps/frontend/src/renderer/styles/globals.css | 4 + apps/frontend/src/shared/constants/task.ts | 3 + .../src/shared/i18n/locales/en/tasks.json | 27 +- .../src/shared/i18n/locales/fr/tasks.json | 24 ++ apps/frontend/src/shared/types/project.ts | 2 + apps/frontend/src/shared/types/task.ts | 2 +- package-lock.json | 3 + package.json | 3 + 19 files changed, 598 insertions(+), 158 deletions(-) create mode 100644 apps/frontend/src/renderer/components/QueueSettingsModal.tsx diff --git a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts index 77fbda79..f44aec1d 100644 --- a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts @@ -76,13 +76,13 @@ export function getPlanPath(project: Project, task: Task): string { */ export function mapStatusToPlanStatus(status: TaskStatus): string { switch (status) { + case 'queue': + return 'queued'; case 'in_progress': return 'in_progress'; case 'ai_review': case 'human_review': return 'review'; - case 'pr_created': - return 'pr_created'; case 'done': return 'completed'; default: diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 1171b837..1b860697 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -1465,9 +1465,9 @@ async function updateTaskStatusAfterPRCreation( // Await status persistence to ensure completion before resolving try { - const persisted = await persistPlanStatus(planPath, 'pr_created'); + const persisted = await persistPlanStatus(planPath, 'done'); result.mainProjectStatus = persisted; - debug('Main project status persisted to pr_created:', persisted); + debug('Main project status persisted to done:', persisted); } catch (err) { debug('Failed to persist main project status:', err); } @@ -1484,9 +1484,9 @@ async function updateTaskStatusAfterPRCreation( const worktreeMetadataPath = path.join(worktreePath, specsBaseDir, specId, 'task_metadata.json'); try { - const persisted = await persistPlanStatus(worktreePlanPath, 'pr_created'); + const persisted = await persistPlanStatus(worktreePlanPath, 'done'); result.worktreeStatus = persisted; - debug('Worktree status persisted to pr_created:', persisted); + debug('Worktree status persisted to done:', persisted); } catch (err) { debug('Failed to persist worktree status:', err); } diff --git a/apps/frontend/src/renderer/__tests__/task-order.test.ts b/apps/frontend/src/renderer/__tests__/task-order.test.ts index b259b35c..020a736a 100644 --- a/apps/frontend/src/renderer/__tests__/task-order.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-order.test.ts @@ -27,11 +27,12 @@ function createTestTask(overrides: Partial = {}): Task { function createTestTaskOrder(overrides: Partial = {}): TaskOrderState { return { backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [], ...overrides }; @@ -96,7 +97,7 @@ describe('Task Order State Management', () => { in_progress: ['task-2'], ai_review: ['task-3'], human_review: ['task-4'], - pr_created: ['task-5'], + queue: ['task-5'], done: ['task-6'] }); @@ -106,7 +107,7 @@ describe('Task Order State Management', () => { expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-2']); expect(useTaskStore.getState().taskOrder?.ai_review).toEqual(['task-3']); expect(useTaskStore.getState().taskOrder?.human_review).toEqual(['task-4']); - expect(useTaskStore.getState().taskOrder?.pr_created).toEqual(['task-5']); + expect(useTaskStore.getState().taskOrder?.queue).toEqual(['task-5']); expect(useTaskStore.getState().taskOrder?.done).toEqual(['task-6']); }); }); @@ -246,11 +247,12 @@ describe('Task Order State Management', () => { expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }); }); @@ -279,11 +281,12 @@ describe('Task Order State Management', () => { // Should fall back to empty order state expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }); expect(consoleSpy).toHaveBeenCalledWith('Failed to load task order:', expect.any(Error)); @@ -306,11 +309,12 @@ describe('Task Order State Management', () => { // Should fall back to empty order state expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }); @@ -498,8 +502,9 @@ describe('Task Order State Management', () => { in_progress: [], ai_review: [], human_review: [], - pr_created: [], + queue: [], done: [], + pr_created: [], error: [] } as TaskOrderState; useTaskStore.setState({ taskOrder: order }); @@ -593,11 +598,12 @@ describe('Task Order State Management', () => { // Empty string causes JSON.parse to throw - should fall back to empty order expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }); @@ -646,7 +652,7 @@ describe('Task Order State Management', () => { in_progress: ['task-4'], ai_review: [], human_review: ['task-5', 'task-6'], - pr_created: [], + queue: [], done: ['task-7', 'task-8', 'task-9', 'task-10'] }); useTaskStore.setState({ taskOrder: order }); diff --git a/apps/frontend/src/renderer/__tests__/task-store.test.ts b/apps/frontend/src/renderer/__tests__/task-store.test.ts index 9ab11f7d..d8dbc391 100644 --- a/apps/frontend/src/renderer/__tests__/task-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-store.test.ts @@ -1259,35 +1259,6 @@ describe('Task Store', () => { // FIX (PR Review): Test coverage for terminal status downgrade prevention describe('terminal status downgrade prevention', () => { - it('should NOT downgrade from pr_created to ai_review', () => { - useTaskStore.setState({ - tasks: [createTestTask({ - id: 'task-1', - status: 'pr_created', - executionProgress: undefined - })] - }); - - const plan = createTestPlan({ - phases: [ - { - phase: 1, - name: 'Phase 1', - type: 'implementation', - subtasks: [ - { id: 'c1', description: 'Subtask 1', status: 'completed' }, - { id: 'c2', description: 'Subtask 2', status: 'completed' } - ] - } - ] - }); - - useTaskStore.getState().updateTaskFromPlan('task-1', plan); - - // Status should remain pr_created, not downgrade to ai_review - expect(useTaskStore.getState().tasks[0].status).toBe('pr_created'); - }); - it('should NOT downgrade from done to ai_review', () => { useTaskStore.setState({ tasks: [createTestTask({ diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 2f22b53b..51f13dc9 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useViewState } from '../contexts/ViewStateContext'; import { @@ -19,16 +19,18 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X } from 'lucide-react'; +import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus } from 'lucide-react'; import { Checkbox } from './ui/checkbox'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { TaskCard } from './TaskCard'; import { SortableTaskCard } from './SortableTaskCard'; +import { QueueSettingsModal } from './QueueSettingsModal'; import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; import { cn } from '../lib/utils'; import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store'; +import { updateProjectSettings, useProjectStore } from '../stores/project-store'; import { useToast } from '../hooks/use-toast'; import { WorktreeCleanupDialog } from './WorktreeCleanupDialog'; import { BulkPRDialog } from './BulkPRDialog'; @@ -68,6 +70,9 @@ interface DroppableColumnProps { isOver: boolean; onAddClick?: () => void; onArchiveAll?: () => void; + onQueueSettings?: () => void; + onQueueAll?: () => void; + maxParallelTasks?: number; archivedCount?: number; showArchived?: boolean; onToggleArchived?: () => void; @@ -116,6 +121,9 @@ function droppableColumnPropsAreEqual( if (prevProps.onStatusChange !== nextProps.onStatusChange) return false; if (prevProps.onAddClick !== nextProps.onAddClick) return false; if (prevProps.onArchiveAll !== nextProps.onArchiveAll) return false; + if (prevProps.onQueueSettings !== nextProps.onQueueSettings) return false; + if (prevProps.onQueueAll !== nextProps.onQueueAll) return false; + if (prevProps.maxParallelTasks !== nextProps.maxParallelTasks) return false; if (prevProps.archivedCount !== nextProps.archivedCount) return false; if (prevProps.showArchived !== nextProps.showArchived) return false; if (prevProps.onToggleArchived !== nextProps.onToggleArchived) return false; @@ -123,14 +131,14 @@ function droppableColumnPropsAreEqual( if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false; if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false; - // Compare selectedTaskIds Set - if (prevProps.selectedTaskIds !== nextProps.selectedTaskIds) { - // If one is undefined and other isn't, different - if (!prevProps.selectedTaskIds || !nextProps.selectedTaskIds) return false; - // Compare Set contents - if (prevProps.selectedTaskIds.size !== nextProps.selectedTaskIds.size) return false; - for (const id of prevProps.selectedTaskIds) { - if (!nextProps.selectedTaskIds.has(id)) return false; + // Compare selection props + const prevSelected = prevProps.selectedTaskIds; + const nextSelected = nextProps.selectedTaskIds; + if (prevSelected !== nextSelected) { + if (!prevSelected || !nextSelected) return false; + if (prevSelected.size !== nextSelected.size) return false; + for (const id of prevSelected) { + if (!nextSelected.has(id)) return false; } } @@ -154,6 +162,12 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { message: t('kanban.emptyBacklog'), subtext: t('kanban.emptyBacklogHint') }; + case 'queue': + return { + icon: , + message: t('kanban.emptyQueue'), + subtext: t('kanban.emptyQueueHint') + }; case 'in_progress': return { icon: , @@ -186,7 +200,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { } }; -const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) { +const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) { const { t } = useTranslation(['tasks', 'common']); const { setNodeRef } = useDroppable({ id: status @@ -267,6 +281,8 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli switch (status) { case 'backlog': return 'column-backlog'; + case 'queue': + return 'column-queue'; case 'in_progress': return 'column-in-progress'; case 'ai_review': @@ -317,20 +333,55 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli

{t(TASK_STATUS_LABELS[status])}

- - {tasks.length} - + {status === 'in_progress' && maxParallelTasks ? ( + = maxParallelTasks && "bg-warning/20 text-warning border-warning/30" + )}> + {tasks.length}/{maxParallelTasks} + + ) : ( + + {tasks.length} + + )}
- {status === 'backlog' && onAddClick && ( + {status === 'backlog' && ( + <> + {onQueueAll && tasks.length > 0 && ( + + )} + {onAddClick && ( + + )} + + )} + {status === 'queue' && onQueueSettings && ( )} {status === 'done' && onArchiveAll && tasks.length > 0 && !showArchived && ( @@ -428,6 +479,20 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const [overColumnId, setOverColumnId] = useState(null); const { showArchived, toggleShowArchived } = useViewState(); + // Project store for queue settings + const projects = useProjectStore((state) => state.projects); + + // Get projectId from first task + const projectId = tasks[0]?.projectId; + const project = projectId ? projects.find((p) => p.id === projectId) : undefined; + const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3; + + // Queue settings modal state + const [showQueueSettings, setShowQueueSettings] = useState(false); + + // Queue processing lock to prevent race conditions + const isProcessingQueueRef = useRef(false); + // Selection state for bulk actions (Human Review column) const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()); @@ -484,6 +549,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR // Note: error tasks are shown in the 'human_review' column since they need human attention const grouped: Record = { backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], @@ -700,6 +766,140 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR } }; + /** + * Move all backlog tasks to queue + */ + const handleQueueAll = async () => { + const backlogTasks = tasksByStatus.backlog; + if (backlogTasks.length === 0) return; + + let movedCount = 0; + for (const task of backlogTasks) { + const result = await persistTaskStatus(task.id, 'queue'); + if (result.success) { + movedCount++; + } else { + console.error(`[Queue] Failed to move task ${task.id} to queue:`, result.error); + } + } + + // Auto-promote queued tasks to fill available capacity + await processQueue(); + + toast({ + title: t('queue.queueAllSuccess', { count: movedCount }), + variant: 'default' + }); + }; + + /** + * Save queue settings (maxParallelTasks) + */ + const handleSaveQueueSettings = async (maxParallel: number) => { + if (!projectId) return; + + const success = await updateProjectSettings(projectId, { maxParallelTasks: maxParallel }); + if (success) { + toast({ + title: t('queue.settings.saved'), + variant: 'default' + }); + } else { + toast({ + title: t('queue.settings.saveFailed'), + description: t('queue.settings.retry'), + variant: 'destructive' + }); + } + }; + + /** + * Automatically move tasks from Queue to In Progress to fill available capacity + * Promotes multiple tasks if needed (e.g., after bulk queue) + */ + const processQueue = useCallback(async () => { + // Prevent concurrent executions to avoid race conditions + if (isProcessingQueueRef.current) { + console.log('[Queue] Already processing queue, skipping duplicate call'); + return; + } + + isProcessingQueueRef.current = true; + + try { + // Track tasks we've already attempted to promote (to avoid infinite retries) + const attemptedTaskIds = new Set(); + let consecutiveFailures = 0; + const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop + + // Loop until capacity is full or queue is empty + while (true) { + // Get CURRENT state from store to ensure accuracy + const currentTasks = useTaskStore.getState().tasks; + const inProgressCount = currentTasks.filter((t) => + t.status === 'in_progress' && !t.metadata?.archivedAt + ).length; + const queuedTasks = currentTasks.filter((t) => + t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id) + ); + + // Stop if no capacity, no queued tasks, or too many consecutive failures + if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) { + break; + } + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`); + break; + } + + // Get the oldest task in queue (FIFO ordering) + const nextTask = queuedTasks.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateA - dateB; // Ascending order (oldest first) + })[0]; + + console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`); + const result = await persistTaskStatus(nextTask.id, 'in_progress'); + + if (result.success) { + // Reset consecutive failures on success + consecutiveFailures = 0; + } else { + // If promotion failed, log error, mark as attempted, and skip to next task + console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error); + attemptedTaskIds.add(nextTask.id); + consecutiveFailures++; + } + } + + // Log if we had failed tasks + if (attemptedTaskIds.size > 0) { + console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`); + } + } finally { + isProcessingQueueRef.current = false; + } + }, [maxParallelTasks]); + + // Register task status change listener for queue auto-promotion + // This ensures processQueue() is called whenever a task leaves in_progress + useEffect(() => { + const unregister = useTaskStore.getState().registerTaskStatusChangeListener( + (taskId, oldStatus, newStatus) => { + // When a task leaves in_progress (e.g., goes to human_review), process the queue + if (oldStatus === 'in_progress' && newStatus !== 'in_progress') { + console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`); + processQueue(); + } + } + ); + + // Cleanup: unregister listener when component unmounts + return unregister; + }, [processQueue]); + // Get task order actions from store const reorderTasksInColumn = useTaskStore((state) => state.reorderTasksInColumn); const moveTaskToColumnTop = useTaskStore((state) => state.moveTaskToColumnTop); @@ -707,9 +907,6 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const loadTaskOrder = useTaskStore((state) => state.loadTaskOrder); const setTaskOrder = useTaskStore((state) => state.setTaskOrder); - // Get projectId from tasks (all tasks in KanbanBoard share the same project) - const projectId = useMemo(() => tasks[0]?.projectId ?? null, [tasks]); - const saveTaskOrder = useCallback((projectIdToSave: string) => { const success = saveTaskOrderToStorage(projectIdToSave); if (!success) { @@ -741,11 +938,12 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR let hasStaleIds = false; const cleanedOrder: typeof taskOrder = { backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }; @@ -768,7 +966,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR } }, [tasks, taskOrder, projectId, setTaskOrder, saveTaskOrder]); - const handleDragEnd = (event: DragEndEvent) => { + const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; setActiveTask(null); setOverColumnId(null); @@ -778,77 +976,103 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const activeTaskId = active.id as string; const overId = over.id as string; + // Determine target status + let newStatus: TaskStatus | null = null; + let oldStatus: TaskStatus | null = null; + + // Get the task being dragged + const task = tasks.find((t) => t.id === activeTaskId); + if (!task) return; + oldStatus = task.status; + // Check if dropped on a column if (isValidDropColumn(overId)) { - const newStatus = overId; - const task = tasks.find((t) => t.id === activeTaskId); + newStatus = overId; + } else { + // Check if dropped on another task - move to that task's column + const overTask = tasks.find((t) => t.id === overId); + if (overTask) { + const task = tasks.find((t) => t.id === activeTaskId); + if (!task) return; - if (task && task.status !== newStatus) { - // Move task to top of target column's order array - moveTaskToColumnTop(activeTaskId, newStatus, task.status); + // Compare visual columns + const taskVisualColumn = getVisualColumn(task.status); + const overTaskVisualColumn = getVisualColumn(overTask.status); + + // Same visual column: reorder within column + if (taskVisualColumn === overTaskVisualColumn) { + // Ensure both tasks are in the order array before reordering + // This handles tasks that existed before ordering was enabled + const currentColumnOrder = taskOrder?.[taskVisualColumn] ?? []; + const activeInOrder = currentColumnOrder.includes(activeTaskId); + const overInOrder = currentColumnOrder.includes(overId); + + if (!activeInOrder || !overInOrder) { + // Sync the current visual order to the stored order + // This ensures existing tasks can be reordered + const visualOrder = tasksByStatus[taskVisualColumn].map(t => t.id); + setTaskOrder({ + ...taskOrder, + [taskVisualColumn]: visualOrder + } as TaskOrderState); + } + + // Reorder tasks within the same column using the visual column key + reorderTasksInColumn(taskVisualColumn, activeTaskId, overId); + + if (projectId) { + saveTaskOrder(projectId); + } + return; + } + + // Different visual column: move to that task's column (status change) + // Use the visual column key for ordering to ensure consistency + newStatus = overTask.status; + moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn); // Persist task order if (projectId) { saveTaskOrder(projectId); } - - // Persist status change to file and update local state - handleStatusChange(activeTaskId, newStatus, task).catch((err) => - console.error('[KanbanBoard] Status change failed:', err) - ); } - return; } - // Check if dropped on another task - const overTask = tasks.find((t) => t.id === overId); - if (overTask) { - const task = tasks.find((t) => t.id === activeTaskId); - if (!task) return; + if (!newStatus || newStatus === oldStatus) return; - // Compare visual columns (pr_created maps to 'done' visually) - const taskVisualColumn = getVisualColumn(task.status); - const overTaskVisualColumn = getVisualColumn(overTask.status); + // ============================================ + // QUEUE SYSTEM: Enforce parallel task limit + // ============================================ + if (newStatus === 'in_progress') { + // Get CURRENT state from store directly to avoid stale prop/memo issues during rapid dragging + const currentTasks = useTaskStore.getState().tasks; + const inProgressCount = currentTasks.filter((t) => + t.status === 'in_progress' && !t.metadata?.archivedAt + ).length; - // Same visual column: reorder within column - if (taskVisualColumn === overTaskVisualColumn) { - // Ensure both tasks are in the order array before reordering - // This handles tasks that existed before ordering was enabled - const currentColumnOrder = taskOrder?.[taskVisualColumn] ?? []; - const activeInOrder = currentColumnOrder.includes(activeTaskId); - const overInOrder = currentColumnOrder.includes(overId); + // If limit reached, move to queue instead + if (inProgressCount >= maxParallelTasks) { + // Only bypass the capacity check if coming from queue AND queue is NOT being processed + // This prevents race condition where both auto-promotion and manual drag exceed the limit + const isAutoPromotionInProgress = oldStatus === 'queue' && isProcessingQueueRef.current; - if (!activeInOrder || !overInOrder) { - // Sync the current visual order to the stored order - // This ensures existing tasks can be reordered - const visualOrder = tasksByStatus[taskVisualColumn].map(t => t.id); - setTaskOrder({ - ...taskOrder, - [taskVisualColumn]: visualOrder - } as TaskOrderState); + if (!isAutoPromotionInProgress) { + console.log(`[Queue] In Progress full (${inProgressCount}/${maxParallelTasks}), moving task to Queue`); + newStatus = 'queue'; } - - // Reorder tasks within the same column using the visual column key - reorderTasksInColumn(taskVisualColumn, activeTaskId, overId); - - if (projectId) { - saveTaskOrder(projectId); - } - return; } + } - // Different visual column: move to that task's column (status change) - // Use the visual column key for ordering to ensure consistency - moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn); + // Persist status change to file and update local state + // Use handleStatusChange to properly handle worktree cleanup dialog + await handleStatusChange(activeTaskId, newStatus, task); - // Persist task order - if (projectId) { - saveTaskOrder(projectId); - } - - handleStatusChange(activeTaskId, overTask.status, task).catch((err) => - console.error('[KanbanBoard] Status change failed:', err) - ); + // ============================================ + // QUEUE SYSTEM: Auto-process queue when slot opens + // ============================================ + if (oldStatus === 'in_progress' && newStatus !== 'in_progress') { + // A task left In Progress - check if we can promote from queue + await processQueue(); } }; @@ -887,7 +1111,10 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR onStatusChange={handleStatusChange} isOver={overColumnId === status} onAddClick={status === 'backlog' ? onNewTaskClick : undefined} + onQueueAll={status === 'backlog' ? handleQueueAll : undefined} + onQueueSettings={status === 'queue' ? () => setShowQueueSettings(true) : undefined} onArchiveAll={status === 'done' ? handleArchiveAll : undefined} + maxParallelTasks={status === 'in_progress' ? maxParallelTasks : undefined} archivedCount={status === 'done' ? archivedCount : undefined} showArchived={status === 'done' ? showArchived : undefined} onToggleArchived={status === 'done' ? toggleShowArchived : undefined} @@ -953,6 +1180,17 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR onConfirm={handleWorktreeCleanupConfirm} /> + {/* Queue Settings Modal */} + {projectId && ( + + )} + {/* Bulk PR creation dialog */} void; + projectId: string; + currentMaxParallel?: number; + onSave: (maxParallel: number) => void; +} + +export function QueueSettingsModal({ + open, + onOpenChange, + projectId, + currentMaxParallel = 3, + onSave +}: QueueSettingsModalProps) { + const { t } = useTranslation(['tasks', 'common']); + const [maxParallel, setMaxParallel] = useState(currentMaxParallel); + const [error, setError] = useState(null); + + // Reset to current value when modal opens + useEffect(() => { + if (open) { + setMaxParallel(currentMaxParallel); + setError(null); + } + }, [open, currentMaxParallel]); + + const handleSave = () => { + // Validate the input + if (maxParallel < 1) { + setError(t('queue.settings.minValueError')); + return; + } + if (maxParallel > 10) { + setError(t('queue.settings.maxValueError')); + return; + } + + onSave(maxParallel); + onOpenChange(false); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + // Handle empty input - allow clearing the field + if (inputValue === '') { + setMaxParallel(0); // Reset to 0 (will fail validation, but allows re-entry) + setError(null); + return; + } + + const value = parseInt(inputValue, 10); + if (!isNaN(value)) { + setMaxParallel(value); + setError(null); + } + }; + + return ( + + + + {t('queue.settings.title')} + + {t('queue.settings.description')} + + + +
+
+ + + {error && ( +

{error}

+ )} +

+ {t('queue.settings.hint')} +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 71ce1971..16e7ca1c 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -316,8 +316,6 @@ export const TaskCard = memo(function TaskCard({ return 'warning'; case 'human_review': return 'purple'; - case 'pr_created': - return 'success'; case 'done': return 'success'; default: @@ -333,8 +331,6 @@ export const TaskCard = memo(function TaskCard({ return t('labels.aiReview'); case 'human_review': return t('labels.needsReview'); - case 'pr_created': - return t('columns.pr_created'); case 'done': return t('status.complete'); default: @@ -452,7 +448,7 @@ export const TaskCard = memo(function TaskCard({ {/* Status badge - hide when execution phase badge is showing */} {!hasActiveExecution && ( <> - {task.status === 'pr_created' ? ( + {task.status === 'done' ? ( {t('actions.resume')} - ) : task.status === 'pr_created' ? ( + ) : task.status === 'done' && task.metadata?.prUrl ? (
{task.metadata?.prUrl && (
- ); - } - - if (task.status === 'pr_created') { + if (task.status === 'done' && task.metadata?.prUrl) { return (
@@ -324,6 +314,15 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, ); } + if (task.status === 'done') { + return ( +
+ + {t('tasks:status.complete')} +
+ ); + } + return null; }; diff --git a/apps/frontend/src/renderer/stores/project-store.ts b/apps/frontend/src/renderer/stores/project-store.ts index 5d95d6e1..5442fdd8 100644 --- a/apps/frontend/src/renderer/stores/project-store.ts +++ b/apps/frontend/src/renderer/stores/project-store.ts @@ -391,8 +391,10 @@ export async function updateProjectSettings( if (result.success) { const project = store.projects.find((p) => p.id === projectId); if (project) { + // Merge settings properly, handling the case where project.settings might be undefined + const currentSettings = project.settings || {}; store.updateProject(projectId, { - settings: { ...project.settings, ...settings } + settings: { ...currentSettings, ...settings } }); } return true; diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 59cb1a2b..b82abacb 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -32,6 +32,9 @@ interface TaskState { saveTaskOrder: (projectId: string) => boolean; clearTaskOrder: (projectId: string) => void; + // Task status change listeners (for queue auto-promotion) + registerTaskStatusChangeListener: (listener: (taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus) => void) => () => void; + // Selectors getSelectedTask: () => Task | undefined; getTasksByStatus: (status: TaskStatus) => Task[]; @@ -45,6 +48,25 @@ function findTaskIndex(tasks: Task[], taskId: string): number { return tasks.findIndex((t) => t.id === taskId || t.specId === taskId); } +/** + * Task status change listeners for queue auto-promotion + * Stored outside the store to avoid triggering re-renders + */ +const taskStatusChangeListeners = new Set<(taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus) => void>(); + +/** + * Notify all registered listeners when a task status changes + */ +function notifyTaskStatusChange(taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus): void { + for (const listener of taskStatusChangeListeners) { + try { + listener(taskId, oldStatus, newStatus); + } catch (error) { + console.error('[TaskStore] Error in task status change listener:', error); + } + } +} + /** * Helper to update a single task efficiently. * Uses slice instead of map to avoid iterating all tasks. @@ -120,11 +142,12 @@ function getTaskOrderKey(projectId: string): string { function createEmptyTaskOrder(): TaskOrderState { return { backlog: [], + queue: [], in_progress: [], ai_review: [], human_review: [], - pr_created: [], done: [], + pr_created: [], error: [] }; } @@ -178,14 +201,22 @@ export const useTaskStore = create((set, get) => ({ }; }), - updateTaskStatus: (taskId, status) => - set((state) => { - const index = findTaskIndex(state.tasks, taskId); - if (index === -1) { - debugLog('[updateTaskStatus] Task not found:', taskId); - return state; - } + updateTaskStatus: (taskId, status) => { + // Capture old status before update + const state = get(); + const index = findTaskIndex(state.tasks, taskId); + if (index === -1) { + debugLog('[updateTaskStatus] Task not found:', taskId); + return; + } + const oldTask = state.tasks[index]; + const oldStatus = oldTask.status; + // Skip if status is the same + if (oldStatus === status) return; + + // Perform the state update + set((state) => { return { tasks: updateTaskAtIndex(state.tasks, index, (t) => { // Determine execution progress based on status transition @@ -218,7 +249,13 @@ export const useTaskStore = create((set, get) => ({ return { ...t, status, executionProgress, updatedAt: new Date() }; }) }; - }), + }); + + // Notify listeners after state update (schedule after current tick) + queueMicrotask(() => { + notifyTaskStatusChange(taskId, oldStatus, status); + }); + }, updateTaskFromPlan: (taskId, plan) => set((state) => { @@ -317,7 +354,7 @@ export const useTaskStore = create((set, get) => ({ // 1. Subtasks array is properly populated (not empty) // 2. All subtasks are actually completed (for 'done' and 'ai_review' statuses) const hasSubtasks = subtasks.length > 0; - const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done']; + const terminalStatuses: TaskStatus[] = ['human_review', 'done']; // If task is currently in a terminal status, validate subtasks before allowing downgrade // This prevents flip-flop when plan file is written with incomplete data @@ -328,8 +365,8 @@ export const useTaskStore = create((set, get) => ({ if (newStatus === 'ai_review' && (!allCompleted || !hasSubtasks)) { return true; } - // For done and pr_created, all subtasks must be completed - if ((newStatus === 'done' || newStatus === 'pr_created') && (!allCompleted || !hasSubtasks)) { + // For done, all subtasks must be completed + if (newStatus === 'done' && (!allCompleted || !hasSubtasks)) { return true; } // For human_review with 'completed' reason, all subtasks must be done @@ -350,7 +387,7 @@ export const useTaskStore = create((set, get) => ({ if (!isInActivePhase && !isInTerminalPhase && !isInTerminalStatus && !isExplicitHumanReview) { if (allCompleted && hasSubtasks) { // FIX (Flip-Flop Bug): Don't downgrade from terminal statuses to ai_review - // Once a task reaches human_review, pr_created, or done, it should stay there + // Once a task reaches human_review or done, it should stay there // unless explicitly changed (these are finalized workflow states) if (!terminalStatuses.includes(t.status)) { status = 'ai_review'; @@ -565,11 +602,12 @@ export const useTaskStore = create((set, get) => ({ const emptyOrder = createEmptyTaskOrder(); const validatedOrder: TaskOrderState = { backlog: isValidColumnArray(parsed.backlog) ? parsed.backlog : emptyOrder.backlog, + queue: isValidColumnArray(parsed.queue) ? parsed.queue : emptyOrder.queue, in_progress: isValidColumnArray(parsed.in_progress) ? parsed.in_progress : emptyOrder.in_progress, ai_review: isValidColumnArray(parsed.ai_review) ? parsed.ai_review : emptyOrder.ai_review, human_review: isValidColumnArray(parsed.human_review) ? parsed.human_review : emptyOrder.human_review, - pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created, done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done, + pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created, error: isValidColumnArray(parsed.error) ? parsed.error : emptyOrder.error }; @@ -618,6 +656,14 @@ export const useTaskStore = create((set, get) => ({ getTasksByStatus: (status) => { const state = get(); return state.tasks.filter((t) => t.status === status); + }, + + registerTaskStatusChangeListener: (listener) => { + taskStatusChangeListeners.add(listener); + // Return cleanup function to unregister + return () => { + taskStatusChangeListeners.delete(listener); + }; } })); diff --git a/apps/frontend/src/renderer/styles/globals.css b/apps/frontend/src/renderer/styles/globals.css index d8ff00d2..dce2d1f3 100644 --- a/apps/frontend/src/renderer/styles/globals.css +++ b/apps/frontend/src/renderer/styles/globals.css @@ -1140,6 +1140,10 @@ body { border-top-color: var(--muted-foreground); } +.column-queue { + border-top-color: #22d3ee; +} + .column-in-progress { border-top-color: var(--info); } diff --git a/apps/frontend/src/shared/constants/task.ts b/apps/frontend/src/shared/constants/task.ts index c0a9f0f5..4f512b40 100644 --- a/apps/frontend/src/shared/constants/task.ts +++ b/apps/frontend/src/shared/constants/task.ts @@ -10,6 +10,7 @@ // Task status columns in Kanban board order export const TASK_STATUS_COLUMNS = [ 'backlog', + 'queue', 'in_progress', 'ai_review', 'human_review', @@ -23,6 +24,7 @@ export type TaskStatusColumn = typeof TASK_STATUS_COLUMNS[number]; // Note: error maps to 'human_review' column in Kanban view (errors need human attention) export const TASK_STATUS_LABELS: Record = { backlog: 'columns.backlog', + queue: 'columns.queue', in_progress: 'columns.in_progress', ai_review: 'columns.ai_review', human_review: 'columns.human_review', @@ -36,6 +38,7 @@ export const TASK_STATUS_LABELS: Record = { backlog: 'bg-muted text-muted-foreground', + queue: 'bg-cyan-500/10 text-cyan-400', in_progress: 'bg-info/10 text-info', ai_review: 'bg-warning/10 text-warning', human_review: 'bg-purple-500/10 text-purple-400', diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index f11e15cb..ffa73122 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -2,6 +2,7 @@ "refreshTasks": "Refresh Tasks", "status": { "backlog": "Backlog", + "queue": "Queue", "todo": "To Do", "in_progress": "In Progress", "review": "Review", @@ -55,6 +56,7 @@ }, "columns": { "backlog": "Planning", + "queue": "Queue", "in_progress": "In Progress", "ai_review": "AI Review", "human_review": "Human Review", @@ -65,8 +67,10 @@ "kanban": { "emptyBacklog": "No tasks planned", "emptyBacklogHint": "Add a task to get started", + "emptyQueue": "Queue is empty", + "emptyQueueHint": "Tasks will wait here when parallel task limit is reached", "emptyInProgress": "Nothing running", - "emptyInProgressHint": "Start a task from Backlog", + "emptyInProgressHint": "Start a task from Planning", "emptyAiReview": "No tasks in review", "emptyAiReviewHint": "AI will review completed tasks", "emptyHumanReview": "Nothing to review", @@ -77,6 +81,7 @@ "dropHere": "Drop here", "showArchived": "Show archived", "addTaskAriaLabel": "Add new task to backlog", + "queueAllAriaLabel": "Move all tasks to queue", "closeTaskDetailsAriaLabel": "Close task details", "editTask": "Edit task", "cannotEditWhileRunning": "Cannot edit while task is running", @@ -86,6 +91,7 @@ "keepWorktree": "Keep Worktree", "deleteWorktree": "Delete Worktree & Mark Done", "refreshTasks": "Refresh Tasks", + "queueSettings": "Queue Settings", "orderSaveFailedTitle": "Reorder not saved", "orderSaveFailedDescription": "Your task order change was applied but couldn't be saved to storage. It will be lost on refresh.", "selectAll": "Select all", @@ -96,6 +102,25 @@ "createPRs": "Create PRs", "clearSelection": "Clear Selection" }, + "queue": { + "limitReached": "Parallel task limit reached ({{current}}/{{max}}). Task moved to queue.", + "movedToQueue": "Task moved to queue.", + "autoPromoted": "Task auto-promoted from queue to In Progress.", + "capacityAvailable": "{{count}} slot(s) available in In Progress.", + "queueAll": "Add All to Queue", + "queueAllSuccess": "Moved {{count}} tasks to queue.", + "settings": { + "title": "Queue Settings", + "description": "Configure the maximum number of tasks that can run in parallel in the \"In Progress\" board", + "maxParallelLabel": "Max Parallel Tasks", + "minValueError": "Must be at least 1", + "maxValueError": "Cannot exceed 10", + "hint": "When this limit is reached, new tasks will wait in the queue before moving to \"In Progress\"", + "saved": "Queue settings saved", + "saveFailed": "Failed to save queue settings", + "retry": "Please try again" + } + }, "execution": { "phases": { "idle": "Idle", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index e589147e..3afdd5ec 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -55,6 +55,7 @@ }, "columns": { "backlog": "Planification", + "queue": "File d'attente", "in_progress": "En cours", "ai_review": "Révision IA", "human_review": "Révision humaine", @@ -65,6 +66,8 @@ "kanban": { "emptyBacklog": "Aucune tâche planifiée", "emptyBacklogHint": "Ajoutez une tâche pour commencer", + "emptyQueue": "La file d'attente est vide", + "emptyQueueHint": "Les tâches attendront ici lorsque la limite de tâches parallèles sera atteinte", "emptyInProgress": "Rien en cours", "emptyInProgressHint": "Démarrez une tâche depuis le Backlog", "emptyAiReview": "Aucune tâche en révision", @@ -77,6 +80,7 @@ "dropHere": "Déposer ici", "showArchived": "Afficher les archivées", "addTaskAriaLabel": "Ajouter une nouvelle tâche au backlog", + "queueAllAriaLabel": "Déplacer toutes les tâches vers la file d'attente", "closeTaskDetailsAriaLabel": "Fermer les détails de la tâche", "editTask": "Modifier la tâche", "cannotEditWhileRunning": "Impossible de modifier pendant l'exécution", @@ -86,6 +90,7 @@ "keepWorktree": "Garder le Worktree", "deleteWorktree": "Supprimer le Worktree & Marquer Terminé", "refreshTasks": "Actualiser les tâches", + "queueSettings": "Paramètres de la file d'attente", "orderSaveFailedTitle": "Réorganisation non enregistrée", "orderSaveFailedDescription": "Votre changement d'ordre des tâches a été appliqué mais n'a pas pu être sauvegardé. Il sera perdu lors du rafraîchissement.", "selectAll": "Tout sélectionner", @@ -96,6 +101,25 @@ "createPRs": "Créer les PRs", "clearSelection": "Effacer la sélection" }, + "queue": { + "limitReached": "Limite de tâches parallèles atteinte ({{current}}/{{max}}). Tâche déplacée vers la file d'attente.", + "movedToQueue": "Tâche déplacée vers la file d'attente.", + "autoPromoted": "Tâche auto-promue de la file d'attente vers En cours.", + "capacityAvailable": "{{count}} emplacement(s) disponible(s) dans En cours.", + "queueAll": "Tout ajouter à la file d'attente", + "queueAllSuccess": "{{count}} tâches déplacées vers la file d'attente.", + "settings": { + "title": "Paramètres de la file d'attente", + "description": "Configurer le nombre maximal de tâches pouvant s'exécuter en parallèle dans le tableau \"En cours\"", + "maxParallelLabel": "Tâches parallèles maximales", + "minValueError": "Doit être au moins 1", + "maxValueError": "Ne peut pas dépasser 10", + "hint": "Lorsque cette limite est atteinte, les nouvelles tâches attendront dans la file avant de passer à \"En cours\"", + "saved": "Paramètres de la file d'attente enregistrés", + "saveFailed": "Échec de l'enregistrement des paramètres", + "retry": "Veuillez réessayer" + } + }, "execution": { "phases": { "idle": "Inactif", diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index a0bd234b..30bca7de 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -26,6 +26,8 @@ export interface ProjectSettings { mainBranch?: string; /** Include CLAUDE.md instructions in agent system prompt (default: true) */ useClaudeMd?: boolean; + /** Maximum parallel tasks allowed (default: 3) */ + maxParallelTasks?: number; } export interface NotificationSettings { diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index d7cce8c6..c78b0b7a 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -5,7 +5,7 @@ import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings'; import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../constants/phase-protocol'; -export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done' | 'error'; +export type TaskStatus = 'backlog' | 'queue' | 'in_progress' | 'ai_review' | 'human_review' | 'done' | 'pr_created' | 'error'; // Maps task status columns to ordered task IDs for kanban board reordering export type TaskOrderState = Record; diff --git a/package-lock.json b/package-lock.json index 63585b13..d6ba16b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "apps/*", "libs/*" ], + "dependencies": { + "lucide-react": "^0.562.0" + }, "devDependencies": { "jsdom": "^27.4.0" }, diff --git a/package.json b/package.json index 2b70b2a1..a5ef4ad4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "devDependencies": { "jsdom": "^27.4.0" }, + "dependencies": { + "lucide-react": "^0.562.0" + }, "overrides": { "@electron/rebuild": "4.0.2" }