feat: Queue System v2 with Auto-Promotion and Smart Task Management (#1203)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Signed-off-by: Joshua Riley <joshua@joshuariley.co.uk>

* 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 <noreply@anthropic.com>
Signed-off-by: Joshua Riley <joshua@joshuariley.co.uk>

* 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 <noreply@anthropic.com>
Signed-off-by: Joshua Riley <joshua@joshuariley.co.uk>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Signed-off-by: Joshua Riley <joshua@joshuariley.co.uk>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
JoshuaRileyDev
2026-01-22 09:19:10 +00:00
committed by GitHub
parent f6ba70d61e
commit 3b87e24d7b
19 changed files with 598 additions and 158 deletions
@@ -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:
@@ -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);
}
@@ -27,11 +27,12 @@ function createTestTask(overrides: Partial<Task> = {}): Task {
function createTestTaskOrder(overrides: Partial<TaskOrderState> = {}): 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 });
@@ -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({
@@ -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: <Loader2 className="h-6 w-6 text-muted-foreground/50" />,
message: t('kanban.emptyQueue'),
subtext: t('kanban.emptyQueueHint')
};
case 'in_progress':
return {
icon: <Loader2 className="h-6 w-6 text-muted-foreground/50" />,
@@ -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
<h2 className="font-semibold text-sm text-foreground">
{t(TASK_STATUS_LABELS[status])}
</h2>
<span className="column-count-badge">
{tasks.length}
</span>
{status === 'in_progress' && maxParallelTasks ? (
<span className={cn(
"column-count-badge",
tasks.length >= maxParallelTasks && "bg-warning/20 text-warning border-warning/30"
)}>
{tasks.length}/{maxParallelTasks}
</span>
) : (
<span className="column-count-badge">
{tasks.length}
</span>
)}
</div>
<div className="flex items-center gap-1">
{status === 'backlog' && onAddClick && (
{status === 'backlog' && (
<>
{onQueueAll && tasks.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors"
onClick={onQueueAll}
title={t('queue.queueAll')}
>
<ListPlus className="h-4 w-4" />
</Button>
)}
{onAddClick && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
onClick={onAddClick}
aria-label={t('kanban.addTaskAriaLabel')}
>
<Plus className="h-4 w-4" />
</Button>
)}
</>
)}
{status === 'queue' && onQueueSettings && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
onClick={onAddClick}
aria-label={t('kanban.addTaskAriaLabel')}
className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors"
onClick={onQueueSettings}
title={t('kanban.queueSettings')}
>
<Plus className="h-4 w-4" />
<Settings className="h-4 w-4" />
</Button>
)}
{status === 'done' && onArchiveAll && tasks.length > 0 && !showArchived && (
@@ -428,6 +479,20 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
const [overColumnId, setOverColumnId] = useState<string | null>(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<Set<string>>(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<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
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<string>();
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 && (
<QueueSettingsModal
open={showQueueSettings}
onOpenChange={setShowQueueSettings}
projectId={projectId}
currentMaxParallel={maxParallelTasks}
onSave={handleSaveQueueSettings}
/>
)}
{/* Bulk PR creation dialog */}
<BulkPRDialog
open={bulkPRDialogOpen}
@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Input } from './ui/input';
interface QueueSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => 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<string | null>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('queue.settings.title')}</DialogTitle>
<DialogDescription>
{t('queue.settings.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="maxParallel">
{t('queue.settings.maxParallelLabel')}
</Label>
<Input
id="maxParallel"
type="number"
min={1}
max={10}
value={maxParallel}
onChange={handleInputChange}
className="w-full"
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<p className="text-sm text-muted-foreground">
{t('queue.settings.hint')}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common:buttons.cancel')}
</Button>
<Button onClick={handleSave}>
{t('common:buttons.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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' ? (
<Badge
variant={getStatusBadgeVariant(task.status)}
className="text-[10px] px-1.5 py-0.5"
@@ -584,7 +580,7 @@ export const TaskCard = memo(function TaskCard({
<Play className="mr-1.5 h-3 w-3" />
{t('actions.resume')}
</Button>
) : task.status === 'pr_created' ? (
) : task.status === 'done' && task.metadata?.prUrl ? (
<div className="flex gap-1">
{task.metadata?.prUrl && (
<Button
@@ -308,7 +308,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
if (result.data.success && result.data.prUrl && !result.data.alreadyExists) {
// Update task in store
useTaskStore.getState().updateTask(prTask.id, {
status: 'pr_created',
status: 'done',
metadata: { ...prTask.metadata, prUrl: result.data.prUrl }
});
}
@@ -630,7 +630,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
{t('common:buttons.createPR')}
</Button>
)}
{task?.status === 'pr_created' && task.metadata?.prUrl && (
{task?.status === 'done' && task.metadata?.prUrl && (
<Button
variant="info"
size="sm"
@@ -187,7 +187,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
// Update single task in store with new status and prUrl (more efficient than reloading all tasks)
if (result.data.success && result.data.prUrl && !result.data.alreadyExists) {
useTaskStore.getState().updateTask(task.id, {
status: 'pr_created',
status: 'done',
metadata: { ...task.metadata, prUrl: result.data.prUrl }
});
}
@@ -220,7 +220,6 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
if (isStuck) return 'warning';
switch (status) {
case 'done':
case 'pr_created':
return 'success';
case 'human_review':
return 'purple';
@@ -294,16 +293,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
);
}
if (task.status === 'done') {
return (
<div className="completion-state text-sm flex items-center gap-2 text-success">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">{t('tasks:status.complete')}</span>
</div>
);
}
if (task.status === 'pr_created') {
if (task.status === 'done' && task.metadata?.prUrl) {
return (
<div className="flex items-center gap-4">
<div className="completion-state text-sm flex items-center gap-2 text-success">
@@ -324,6 +314,15 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
);
}
if (task.status === 'done') {
return (
<div className="completion-state text-sm flex items-center gap-2 text-success">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">{t('tasks:status.complete')}</span>
</div>
);
}
return null;
};
@@ -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;
+60 -14
View File
@@ -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<TaskState>((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<TaskState>((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<TaskState>((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<TaskState>((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<TaskState>((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<TaskState>((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<TaskState>((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);
};
}
}));
@@ -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);
}
@@ -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<TaskStatusColumn | 'pr_created' | 'error', string> = {
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<TaskStatusColumn | 'pr_created' | 'error
// Note: error maps to 'human_review' column in Kanban view (errors need human attention)
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created' | 'error', string> = {
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',
@@ -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",
@@ -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",
@@ -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 {
+1 -1
View File
@@ -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<TaskStatus, string[]>;
+3
View File
@@ -12,6 +12,9 @@
"apps/*",
"libs/*"
],
"dependencies": {
"lucide-react": "^0.562.0"
},
"devDependencies": {
"jsdom": "^27.4.0"
},
+3
View File
@@ -44,6 +44,9 @@
"devDependencies": {
"jsdom": "^27.4.0"
},
"dependencies": {
"lucide-react": "^0.562.0"
},
"overrides": {
"@electron/rebuild": "4.0.2"
}