import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { fetchWorkflowSteps, type ModelInfo } from "../api"; import { useBlockerFanout } from "../hooks/useBlockerFanout"; 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, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => 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; /** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */ staleHighFanoutBlockerAgeThresholdMs?: number; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; } 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, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs }: BoardProps) { const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP); const blockerFanoutMap = useBlockerFanout(tasks, { staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs, }); // 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: Record = { triage: [], todo: [], "in-progress": [], "in-review": [], done: [], archived: [], }; for (const task of tasks) { const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN; const bucket = nextGrouped[column] ?? nextGrouped[DEFAULT_COLUMN]; bucket.push(task); } const previousGrouped = tasksByColumnCacheRef.current; const stableGrouped = {} as Record; for (const column of COLUMNS) { const sortedTasks = sortTasksForDisplayColumn(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]); // FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`, // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. return ( <>
{COLUMNS.map((col) => ( ))}
); }