Draggable Kanban Task Reordering (#1217)

* auto-claude: subtask-1-1 - Add TaskOrderState type to task.ts

Add TaskOrderState type as Record<TaskStatus, string[]> to map kanban
columns to ordered task IDs for drag-and-drop reordering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Add task order state and actions to task-store.ts

- Import arrayMove from @dnd-kit/sortable
- Import TaskOrderState type from shared types
- Add taskOrder state (TaskOrderState | null) for per-column ordering
- Add setTaskOrder action to set full task order state
- Add reorderTasksInColumn action using arrayMove pattern
- Add loadTaskOrder action to load from localStorage
- Add saveTaskOrder action to persist to localStorage
- Add helper functions: getTaskOrderKey, createEmptyTaskOrder
- Update clearTasks to also clear taskOrder state

* auto-claude: subtask-1-3 - Add localStorage persistence helpers for task order

Add clearTaskOrder function to complete the localStorage persistence
helpers for task order management. The loadTaskOrder and saveTaskOrder
functions were already implemented. This adds:
- clearTaskOrder(projectId): Removes task order from localStorage and resets state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Import arrayMove from @dnd-kit/sortable and add wi

- Import arrayMove from @dnd-kit/sortable in KanbanBoard.tsx
- Import useTaskStore to access reorderTasksInColumn and saveTaskOrder actions
- Add within-column reorder logic to handleDragEnd:
  - Detect same-column drops (when task.status === overTask.status)
  - Call reorderTasksInColumn to update order in store via arrayMove
  - Persist order to localStorage via saveTaskOrder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Update tasksByStatus useMemo to apply custom order

Updated the tasksByStatus useMemo in KanbanBoard to support custom task ordering:
- Added taskOrder state selector from useTaskStore
- If custom order exists for a column, sort tasks by their order index
- Filter out stale IDs (task IDs in order that no longer exist)
- Prepend new tasks (not in order) at top with createdAt sort
- Fallback to createdAt sort (newest first) when no custom order exists

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-3 - Add useEffect to load task order on mount and when project changes

* auto-claude: subtask-3-1 - Handle cross-column drag: place task at top

When a task is moved to a new column via drag-and-drop:
- Added moveTaskToColumnTop action to task-store.ts
- Removes task from source column order array
- Adds task to index 0 (top) of target column order array
- Persists order to localStorage after cross-column moves
- Works for both dropping on column and dropping on task in diff column

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Handle new task placement: new tasks added to backlog appear at index 0

- Modified addTask() to also update taskOrder state when adding new tasks
- New tasks are inserted at index 0 (top) of their status column's order array
- Follows the existing pattern from moveTaskToColumnTop()
- Includes safety check to prevent duplicate task IDs in order array

* auto-claude: subtask-3-3 - Handle deleted/stale tasks in kanban order

Add cleanup effect that detects and removes stale task IDs from the
persisted task order when tasks are deleted. This ensures the order
stored in localStorage stays in sync with actual tasks.

- Add setTaskOrder store selector for updating order state
- Add useEffect that filters out stale IDs when tasks change
- Persist cleaned order to localStorage when stale IDs are found

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Create unit tests for task order state management

Add comprehensive unit tests for kanban board drag-and-drop reordering:
- Test setTaskOrder: basic setting, replacing, empty columns, all column orders
- Test reorderTasksInColumn with arrayMove: various reordering scenarios,
  edge cases (null order, missing IDs, single task, adjacent tasks)
- Test loadTaskOrder: localStorage retrieval, empty state creation,
  project-specific keys, error handling for corrupted/inaccessible data
- Test saveTaskOrder: localStorage persistence, null handling, error handling
- Test clearTaskOrder: removal from localStorage, project-specific keys
- Test moveTaskToColumnTop: cross-column moves, source removal, deduplication
- Test addTask integration: new tasks added to top of column order
- Integration tests: full load/reorder/save cycle, project switching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Add localStorage persistence edge case tests

* auto-claude: subtask-4-3 - Add unit tests for order filtering

Add comprehensive unit tests for task order filtering logic:
- Stale ID removal tests: verify IDs for deleted tasks are filtered out
- New task placement tests: verify new tasks appear at top of column
- Cross-column move tests: verify order updates when tasks move between columns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(kanban): address follow-up review findings

- Wrap saveTaskOrder in useCallback to prevent useEffect dependency loop
- Add runtime validation for localStorage data in loadTaskOrder
- Remove variable shadowing of projectId in handleDragEnd

* test: update task-order tests to match new validation behavior

loadTaskOrder now validates localStorage data and resets to empty order
when invalid data (null, arrays) is found instead of storing it directly.

* fix(kanban): improve task order validation and reordering reliability

- Add comprehensive validation for localStorage task order data:
  - Validate each column value is a string array
  - Merge with empty order to handle partial/corrupted data
- Fix saveTaskOrder to return false when nothing to save
- Fix reordering for tasks not yet in order array by syncing
  visual order before calling reorderTasksInColumn

Resolves PR review findings: NEWCODE-001, NEW-001, NEW-005

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-17 23:16:37 +01:00
committed by GitHub
parent 39236f185c
commit 3606a63293
6 changed files with 1529 additions and 21 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { useState, useMemo, memo, useCallback, useEffect } from 'react';
import { useState, useMemo, memo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useViewState } from '../contexts/ViewStateContext';
import {
@@ -28,11 +28,11 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { cn } from '../lib/utils';
import { persistTaskStatus, forceCompleteTask, archiveTasks } from '../stores/task-store';
import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store';
import { useToast } from '../hooks/use-toast';
import { WorktreeCleanupDialog } from './WorktreeCleanupDialog';
import { BulkPRDialog } from './BulkPRDialog';
import type { Task, TaskStatus } from '../../shared/types';
import type { Task, TaskStatus, TaskOrderState } from '../../shared/types';
// Type guard for valid drop column targets - preserves literal type from TASK_STATUS_COLUMNS
const VALID_DROP_COLUMNS = new Set<string>(TASK_STATUS_COLUMNS);
@@ -40,6 +40,15 @@ function isValidDropColumn(id: string): id is typeof TASK_STATUS_COLUMNS[number]
return VALID_DROP_COLUMNS.has(id);
}
/**
* Get the visual column for a task status.
* pr_created tasks are displayed in the 'done' column, so we map them accordingly.
* This is used to compare visual positions during drag-and-drop operations.
*/
function getVisualColumn(status: TaskStatus): typeof TASK_STATUS_COLUMNS[number] {
return status === 'pr_created' ? 'done' : status;
}
interface KanbanBoardProps {
tasks: Task[];
onTaskClick: (task: Task) => void;
@@ -464,6 +473,9 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
})
);
// Get task order from store for custom ordering
const taskOrder = useTaskStore((state) => state.taskOrder);
const tasksByStatus = useMemo(() => {
// Note: pr_created tasks are shown in the 'done' column since they're essentially complete
const grouped: Record<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
@@ -482,17 +494,51 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
}
});
// Sort tasks within each column by createdAt (newest first)
// Sort tasks within each column
Object.keys(grouped).forEach((status) => {
grouped[status as typeof TASK_STATUS_COLUMNS[number]].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA; // Descending order (newest first)
});
const statusKey = status as typeof TASK_STATUS_COLUMNS[number];
const columnTasks = grouped[statusKey];
const columnOrder = taskOrder?.[statusKey];
if (columnOrder && columnOrder.length > 0) {
// Custom order exists: sort by order index
// 1. Create a set of current task IDs for fast lookup (filters stale IDs)
const currentTaskIds = new Set(columnTasks.map(t => t.id));
// 2. Create valid order by filtering out stale IDs
const validOrder = columnOrder.filter(id => currentTaskIds.has(id));
const validOrderSet = new Set(validOrder);
// 3. Find new tasks not in order (prepend at top)
const newTasks = columnTasks.filter(t => !validOrderSet.has(t.id));
// Sort new tasks by createdAt (newest first)
newTasks.sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA;
});
// 4. Sort ordered tasks by their index in validOrder
// Pre-compute index map for O(n) sorting instead of O(n²) with indexOf
const indexMap = new Map(validOrder.map((id, idx) => [id, idx]));
const orderedTasks = columnTasks
.filter(t => validOrderSet.has(t.id))
.sort((a, b) => (indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0));
// 5. Prepend new tasks at top, then ordered tasks
grouped[statusKey] = [...newTasks, ...orderedTasks];
} else {
// No custom order: fallback to createdAt sort (newest first)
grouped[statusKey].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA;
});
}
});
return grouped;
}, [filteredTasks]);
}, [filteredTasks, taskOrder]);
// Prune stale IDs when tasks move out of human_review column
useEffect(() => {
@@ -650,6 +696,73 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
}
};
// Get task order actions from store
const reorderTasksInColumn = useTaskStore((state) => state.reorderTasksInColumn);
const moveTaskToColumnTop = useTaskStore((state) => state.moveTaskToColumnTop);
const saveTaskOrderToStorage = useTaskStore((state) => state.saveTaskOrder);
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) {
toast({
title: t('kanban.orderSaveFailedTitle'),
description: t('kanban.orderSaveFailedDescription'),
variant: 'destructive'
});
}
return success;
}, [saveTaskOrderToStorage, toast, t]);
// Load task order on mount and when project changes
useEffect(() => {
if (projectId) {
loadTaskOrder(projectId);
}
}, [projectId, loadTaskOrder]);
// Clean up stale task IDs from order when tasks change (e.g., after deletion)
// This ensures the persisted order doesn't contain IDs for deleted tasks
useEffect(() => {
if (!projectId || !taskOrder) return;
// Build a set of current task IDs for fast lookup
const currentTaskIds = new Set(tasks.map(t => t.id));
// Check each column for stale IDs
let hasStaleIds = false;
const cleanedOrder: typeof taskOrder = {
backlog: [],
in_progress: [],
ai_review: [],
human_review: [],
pr_created: [],
done: []
};
for (const status of Object.keys(taskOrder) as Array<keyof typeof taskOrder>) {
const columnOrder = taskOrder[status] || [];
const cleanedColumnOrder = columnOrder.filter(id => currentTaskIds.has(id));
cleanedOrder[status] = cleanedColumnOrder;
// Check if any IDs were removed
if (cleanedColumnOrder.length !== columnOrder.length) {
hasStaleIds = true;
}
}
// If stale IDs were found, update the order and persist
if (hasStaleIds) {
setTaskOrder(cleanedOrder);
saveTaskOrder(projectId);
}
}, [tasks, taskOrder, projectId, setTaskOrder, saveTaskOrder]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveTask(null);
@@ -666,6 +779,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
const task = tasks.find((t) => t.id === activeTaskId);
if (task && task.status !== newStatus) {
// Move task to top of target column's order array
moveTaskToColumnTop(activeTaskId, newStatus, task.status);
// 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)
@@ -674,16 +795,55 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return;
}
// Check if dropped on another task - move to that task's column
// 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 && task.status !== overTask.status) {
// Persist status change to file and update local state
handleStatusChange(activeTaskId, overTask.status, task).catch((err) =>
console.error('[KanbanBoard] Status change failed:', err)
);
if (!task) return;
// Compare visual columns (pr_created maps to 'done' visually)
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
moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn);
// Persist task order
if (projectId) {
saveTaskOrder(projectId);
}
handleStatusChange(activeTaskId, overTask.status, task).catch((err) =>
console.error('[KanbanBoard] Status change failed:', err)
);
}
};
+181 -5
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment } from '../../shared/types';
import { arrayMove } from '@dnd-kit/sortable';
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState } from '../../shared/types';
import { debugLog } from '../../shared/utils/debug-logger';
import { isTerminalPhase } from '../../shared/constants/phase-protocol';
@@ -8,6 +9,7 @@ interface TaskState {
selectedTaskId: string | null;
isLoading: boolean;
error: string | null;
taskOrder: TaskOrderState | null; // Per-column task ordering for kanban board
// Actions
setTasks: (tasks: Task[]) => void;
@@ -22,6 +24,13 @@ interface TaskState {
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearTasks: () => void;
// Task order actions for kanban drag-and-drop reordering
setTaskOrder: (order: TaskOrderState) => void;
reorderTasksInColumn: (status: TaskStatus, activeId: string, overId: string) => void;
moveTaskToColumnTop: (taskId: string, targetStatus: TaskStatus, sourceStatus?: TaskStatus) => void;
loadTaskOrder: (projectId: string) => void;
saveTaskOrder: (projectId: string) => boolean;
clearTaskOrder: (projectId: string) => void;
// Selectors
getSelectedTask: () => Task | undefined;
@@ -95,18 +104,68 @@ function validatePlanData(plan: ImplementationPlan): boolean {
return true;
}
// localStorage key prefix for task order persistence
const TASK_ORDER_KEY_PREFIX = 'task-order-state';
/**
* Get the localStorage key for a project's task order
*/
function getTaskOrderKey(projectId: string): string {
return `${TASK_ORDER_KEY_PREFIX}-${projectId}`;
}
/**
* Create an empty task order state with all status columns
*/
function createEmptyTaskOrder(): TaskOrderState {
return {
backlog: [],
in_progress: [],
ai_review: [],
human_review: [],
pr_created: [],
done: []
};
}
export const useTaskStore = create<TaskState>((set, get) => ({
tasks: [],
selectedTaskId: null,
isLoading: false,
error: null,
taskOrder: null,
setTasks: (tasks) => set({ tasks }),
addTask: (task) =>
set((state) => ({
tasks: [...state.tasks, task]
})),
set((state) => {
// Determine which column the task belongs to based on its status
const status = task.status || 'backlog';
// Update task order if it exists - new tasks go to top of their column
let taskOrder = state.taskOrder;
if (taskOrder) {
const newTaskOrder = { ...taskOrder };
// Add task ID to the top of the appropriate column
if (newTaskOrder[status]) {
// Ensure the task isn't already in the array (safety check)
newTaskOrder[status] = newTaskOrder[status].filter(id => id !== task.id);
// Add to top (index 0)
newTaskOrder[status] = [task.id, ...newTaskOrder[status]];
} else {
// Initialize column order array if it doesn't exist
newTaskOrder[status] = [task.id];
}
taskOrder = newTaskOrder;
}
return {
tasks: [...state.tasks, task],
taskOrder
};
}),
updateTask: (taskId, updates) =>
set((state) => {
@@ -402,7 +461,124 @@ export const useTaskStore = create<TaskState>((set, get) => ({
setError: (error) => set({ error }),
clearTasks: () => set({ tasks: [], selectedTaskId: null }),
clearTasks: () => set({ tasks: [], selectedTaskId: null, taskOrder: null }),
// Task order actions for kanban drag-and-drop reordering
setTaskOrder: (order) => set({ taskOrder: order }),
reorderTasksInColumn: (status, activeId, overId) => {
set((state) => {
if (!state.taskOrder) return state;
const columnOrder = state.taskOrder[status];
if (!columnOrder) return state;
const oldIndex = columnOrder.indexOf(activeId);
const newIndex = columnOrder.indexOf(overId);
// Both tasks must be in the column order array
if (oldIndex === -1 || newIndex === -1) return state;
return {
taskOrder: {
...state.taskOrder,
[status]: arrayMove(columnOrder, oldIndex, newIndex)
}
};
});
},
moveTaskToColumnTop: (taskId, targetStatus, sourceStatus) => {
set((state) => {
if (!state.taskOrder) return state;
// Create a copy of the task order to modify
const newTaskOrder = { ...state.taskOrder };
// Remove from source column if provided
if (sourceStatus && newTaskOrder[sourceStatus]) {
newTaskOrder[sourceStatus] = newTaskOrder[sourceStatus].filter(id => id !== taskId);
}
// Add to top of target column
if (newTaskOrder[targetStatus]) {
// Remove from target column first (in case it already exists there)
newTaskOrder[targetStatus] = newTaskOrder[targetStatus].filter(id => id !== taskId);
// Add to top (index 0)
newTaskOrder[targetStatus] = [taskId, ...newTaskOrder[targetStatus]];
} else {
// Initialize column order array if it doesn't exist
newTaskOrder[targetStatus] = [taskId];
}
return { taskOrder: newTaskOrder };
});
},
loadTaskOrder: (projectId) => {
try {
const key = getTaskOrderKey(projectId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
// Validate structure before assigning - type assertion is compile-time only
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Invalid task order data in localStorage, resetting to empty');
set({ taskOrder: createEmptyTaskOrder() });
return;
}
// Helper to validate column values are string arrays
const isValidColumnArray = (val: unknown): val is string[] =>
Array.isArray(val) && val.every(item => typeof item === 'string');
// Merge with empty order to handle partial data and validate each column
const emptyOrder = createEmptyTaskOrder();
const validatedOrder: TaskOrderState = {
backlog: isValidColumnArray(parsed.backlog) ? parsed.backlog : emptyOrder.backlog,
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
};
set({ taskOrder: validatedOrder });
} else {
set({ taskOrder: createEmptyTaskOrder() });
}
} catch (error) {
console.error('Failed to load task order:', error);
set({ taskOrder: createEmptyTaskOrder() });
}
},
saveTaskOrder: (projectId) => {
try {
const state = get();
if (!state.taskOrder) {
// Nothing to save - return false to indicate no save occurred
return false;
}
const key = getTaskOrderKey(projectId);
localStorage.setItem(key, JSON.stringify(state.taskOrder));
return true;
} catch (error) {
console.error('Failed to save task order:', error);
return false;
}
},
clearTaskOrder: (projectId) => {
try {
const key = getTaskOrderKey(projectId);
localStorage.removeItem(key);
set({ taskOrder: null });
} catch (error) {
console.error('Failed to clear task order:', error);
}
},
getSelectedTask: () => {
const state = get();
@@ -85,6 +85,8 @@
"keepWorktree": "Keep Worktree",
"deleteWorktree": "Delete Worktree & Mark Done",
"refreshTasks": "Refresh Tasks",
"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",
"deselectAll": "Deselect all",
"selectedCount": "{{count}} selected",
@@ -85,6 +85,8 @@
"keepWorktree": "Garder le Worktree",
"deleteWorktree": "Supprimer le Worktree & Marquer Terminé",
"refreshTasks": "Actualiser les tâches",
"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",
"deselectAll": "Tout désélectionner",
"selectedCount": "{{count}} sélectionné(s)",
+3
View File
@@ -7,6 +7,9 @@ import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done';
// Maps task status columns to ordered task IDs for kanban board reordering
export type TaskOrderState = Record<TaskStatus, string[]>;
// Reason why a task is in human_review status
// - 'completed': All subtasks done and QA passed, ready for final approval/merge
// - 'errors': Subtasks failed during execution