import { memo, useMemo, useState, useCallback, useEffect } from "react"; import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core"; import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core"; import { TaskCard } from "./TaskCard"; import { WorktreeGroup } from "./WorktreeGroup"; import { QuickEntryBox } from "./QuickEntryBox"; import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; import { ChevronDown, ChevronUp, Archive } from "lucide-react"; import type { ModelInfo } from "../api"; const PAGINATED_COLUMN_THRESHOLD = 100; const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INCREMENT = 25; interface ColumnProps { column: ColumnType; tasks: Task[]; projectId?: string; maxConcurrent: number; onMoveTask: (id: string, column: ColumnType) => 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; onArchiveTask?: (id: string) => Promise; onUnarchiveTask?: (id: string) => Promise; onDeleteTask?: (id: string) => Promise; onArchiveAllDone?: () => Promise; collapsed?: boolean; onToggleCollapse?: () => void; allTasks?: Task[]; 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; /** When true, search is active — bypass pagination so all matching tasks are visible. */ isSearchActive?: boolean; /** 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; /** Lookup of workflow step IDs to display names, fetched once at board level. */ workflowStepNameLookup?: ReadonlyMap; } function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) { const [dragOver, setDragOver] = useState(false); const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL); const countFlashing = useFlashOnIncrease(tasks.length); // Archived column is collapsed by default - don't show drag state when collapsed const isArchived = column === "archived"; const isCollapsed = isArchived && collapsed; // When search is active, skip pagination so all matching tasks are visible const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); }, [column, isArchived, tasks.length]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop if (isArchived) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(true); }, [isArchived]); const handleDragLeave = useCallback((e: React.DragEvent) => { const el = e.currentTarget as HTMLElement; if (!el.contains(e.relatedTarget as Node)) { setDragOver(false); } }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); const taskId = e.dataTransfer.getData("text/plain"); if (!taskId) return; // Check if task is already in this column - if so, skip the API call const task = tasks.find(t => t.id === taskId); if (task && task.column === column) { return; // No-op: task is already in this column } try { await onMoveTask(taskId, column); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [column, onMoveTask, addToast, tasks]); const worktreeGroups = useMemo(() => { if (column !== "in-progress") return []; return groupByWorktree(tasks, tasks, maxConcurrent); }, [column, tasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; return tasks.slice(0, visibleTaskCount); }, [shouldPaginate, tasks, visibleTaskCount]); const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length); const handleLoadMore = useCallback(() => { setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); }, [tasks.length]); const handleArchiveAll = useCallback(async () => { if (!onArchiveAllDone) return; if (tasks.length === 0) return; const confirmed = window.confirm(`Archive all ${tasks.length} done tasks?`); if (!confirmed) return; try { const archived = await onArchiveAllDone(); addToast(`Archived ${archived.length} tasks`, "success"); } catch (err) { addToast(getErrorMessage(err) || "Failed to archive tasks", "error"); } }, [onArchiveAllDone, tasks.length, addToast]); return (

{COLUMN_LABELS[column]}

{tasks.length} {column === "in-review" && onToggleAutoMerge && ( )} {onNewTask && ( )} {column === "done" && onArchiveAllDone && ( )} {isArchived && onToggleCollapse && ( )}
{!isCollapsed &&

{COLUMN_DESCRIPTIONS[column]}

} {!isCollapsed && (
{column === "triage" && onQuickCreate && ( )} {column === "in-progress" ? ( worktreeGroups.length === 0 ? (
No tasks
) : ( worktreeGroups.map((group) => ( )) ) ) : tasks.length === 0 ? (
No tasks
) : ( <> {visibleTasks.map((task) => ( ))} {shouldPaginate && hiddenTaskCount > 0 && ( )} )}
)}
); } export const Column = memo(ColumnComponent); Column.displayName = "Column";