import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core"; import { COLUMNS } from "@fusion/core"; import { compareTaskPriority, sortTasksByPriorityThenAgeAndId } from "../../../core/src/task-priority"; import { Column } from "./Column"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch"; import { fetchWorkflowSteps, type ModelInfo } from "../api"; interface BoardProps { tasks: Task[]; projectId?: string; maxConcurrent: number; onMoveTask: (id: string, column: ColumnType) => Promise; onPauseTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; onNewTask: () => void; autoMerge: boolean; onToggleAutoMerge: () => void; globalPaused?: boolean; onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ) => Promise; onRetryTask?: (id: string) => Promise; onArchiveTask?: (id: string) => Promise; onUnarchiveTask?: (id: string) => Promise; onDeleteTask?: (id: string) => Promise; onArchiveAllDone?: () => Promise; /** Lazy-load archived tasks. Called the first time the user expands the archived column. */ onLoadArchivedTasks?: () => Promise; searchQuery?: string; availableModels?: ModelInfo[]; /** * Called when the user clicks the "Plan" button in the inline create card. */ onPlanningMode?: (initialPlan: string) => void; /** * Called when the user clicks the "Subtask" button in the inline create card. */ onSubtaskBreakdown?: (description: string) => void; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; /** Called when user clicks a mission badge on a task card */ onOpenMission?: (missionId: string) => void; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; } function compareTaskIdNumeric(a: string, b: string): number { const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10); const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10); if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) { return aNum - bNum; } return a.localeCompare(b); } function getDoneSortTimestamp(task: Task): number { const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt; const parsed = Date.parse(timestamp); return Number.isFinite(parsed) ? parsed : 0; } function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] { if (column === "todo") { // Match scheduler pickup order: priority DESC, createdAt ASC, id ASC. return sortTasksByPriorityThenAgeAndId(tasks); } return [...tasks].sort((a, b) => { if (column === "done") { const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a); if (timestampCmp !== 0) { return timestampCmp; } // Deterministic tie-breaker when completion timestamps match. return compareTaskIdNumeric(a.id, b.id); } // In the in-review column, merging tasks stay pinned above non-merging tasks. if (column === "in-review") { const aIsMerging = a.status === "merging" || a.status === "merging-pr" || a.status === "merging-fix"; const bIsMerging = b.status === "merging" || b.status === "merging-pr" || b.status === "merging-fix"; if (aIsMerging !== bIsMerging) { return aIsMerging ? -1 : 1; } } // Primary sort for non-done/non-todo columns: priority descending. const priorityCmp = compareTaskPriority(a.priority, b.priority); if (priorityCmp !== 0) { return priorityCmp; } // Secondary sort: numeric task ID ascending (lower number first). return compareTaskIdNumeric(a.id, b.id); }); } function areTaskArraysEqual(previous: Task[], next: Task[]): boolean { if (previous.length !== next.length) return false; return previous.every((task, index) => task === next[index]); } const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap = new Map(); function areWorkflowNameLookupsEqual(previous: ReadonlyMap, next: ReadonlyMap): boolean { if (previous.size !== next.size) return false; for (const [key, value] of previous) { if (next.get(key) !== value) return false; } return true; } export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) { const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); const { fetchBatch } = useBatchBadgeFetch(projectId); const debounceTimeoutRef = useRef | null>(null); const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP); // Normalized search-active signal: trimmed and non-empty const isSearchActive = searchQuery.trim() !== ""; const tasksByColumnCacheRef = useRef>({ triage: [], todo: [], "in-progress": [], "in-review": [], done: [], archived: [], }); const handleToggleArchivedCollapse = useCallback(() => { setArchivedCollapsed((current) => { const next = !current; if (!next && !archivedLoadedRef.current && onLoadArchivedTasks) { archivedLoadedRef.current = true; void onLoadArchivedTasks(); } return next; }); }, [onLoadArchivedTasks]); // Tasks are already server-filtered when searchQuery is active (via useTasks hook). // Client-side filtering is removed - tasks prop is used directly. // Keep per-column array identities stable for unchanged columns so React.memo(Column) // can skip sibling rerenders during unrelated task updates. const tasksByColumn = useMemo(() => { const nextGrouped = Object.fromEntries( COLUMNS.map((column) => [column, [] as Task[]]), ) as Record; for (const task of tasks) { nextGrouped[task.column].push(task); } const previousGrouped = tasksByColumnCacheRef.current; const stableGrouped = {} as Record; for (const column of COLUMNS) { const sortedTasks = sortTasksForColumn(nextGrouped[column], column); stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks) ? previousGrouped[column] : sortedTasks; } tasksByColumnCacheRef.current = stableGrouped; return stableGrouped; }, [tasks]); useEffect(() => { let cancelled = false; fetchWorkflowSteps(projectId) .then((steps) => { if (cancelled) return; const nextLookup = new Map(steps.map((step) => [step.id, step.name] as const)); setWorkflowStepNameLookup((previous) => ( areWorkflowNameLookupsEqual(previous, nextLookup) ? previous : nextLookup )); }) .catch(() => { if (cancelled) return; setWorkflowStepNameLookup((previous) => (previous.size === 0 ? previous : EMPTY_WORKFLOW_STEP_NAME_LOOKUP)); }); return () => { cancelled = true; }; }, [projectId]); // Collect task IDs with GitHub badge info for batch fetching const taskIdsWithBadges = useMemo(() => { return tasks .filter((t) => t.prInfo || t.issueInfo) .map((t) => t.id); }, [tasks]); // Batch fetch badge statuses on mount and when visible tasks change useEffect(() => { if (taskIdsWithBadges.length === 0) return; // Debounce the batch fetch to handle rapid changes if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } debounceTimeoutRef.current = setTimeout(() => { // Fetch in chunks of 50 to respect the API limit const chunks: string[][] = []; for (let i = 0; i < taskIdsWithBadges.length; i += 50) { chunks.push(taskIdsWithBadges.slice(i, i + 50)); } // Fire all chunks concurrently - the hook handles deduplication chunks.forEach((chunk) => { void fetchBatch(chunk); }); }, 500); return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } }; }, [taskIdsWithBadges, fetchBatch]); return ( <>
{COLUMNS.map((col) => ( ))}
); }