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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Generated
+3
@@ -12,6 +12,9 @@
|
||||
"apps/*",
|
||||
"libs/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.562.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.562.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@electron/rebuild": "4.0.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user