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:
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)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user