import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react"; import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import { useConfirm } from "../hooks/useConfirm"; import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } 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, MoreVertical } from "lucide-react"; import type { ModelInfo } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; 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, optionsOrPosition?: { preserveProgress?: boolean } | number) => 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; 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" | "retries") => 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; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; } function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap }: ColumnProps) { const [dragOver, setDragOver] = useState(false); const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isReplanning, setIsReplanning] = useState(false); const [isPausingAll, setIsPausingAll] = useState(false); const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false); const menuRef = useRef(null); const countFlashing = useFlashOnIncrease(tasks.length); const { confirm } = useConfirm(); // Close the column dropdown menu when the user clicks anywhere else. useEffect(() => { if (!isMenuOpen) return; function onDocClick(e: MouseEvent) { if (!menuRef.current?.contains(e.target as Node)) { setIsMenuOpen(false); } } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setIsMenuOpen(false); } document.addEventListener("mousedown", onDocClick); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDocClick); document.removeEventListener("keydown", onKey); }; }, [isMenuOpen]); // 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 { const sourceTask = allTasks?.find((t) => t.id === taskId) ?? task; const hasStepProgress = sourceTask?.steps.some((step) => step.status !== "pending") ?? false; const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress; let moveOptions: { preserveProgress?: boolean } | undefined; if (shouldPrompt) { const keepProgress = await confirm({ title: "Preserve Progress?", message: "This task has completed steps. Keep progress before moving?", confirmLabel: "Keep Progress", cancelLabel: "Reset Progress", }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: "Reset Progress?", message: "Reset all step progress before moving this task?", confirmLabel: "Reset Progress", cancelLabel: "Cancel Move", danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(taskId, column, moveOptions); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [addToast, allTasks, column, confirm, onMoveTask, 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 handleReplanAll = useCallback(async () => { setIsMenuOpen(false); if (tasks.length === 0) return; const confirmed = await confirm({ title: "Replan All Tasks", message: `Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to planning to be replanned?`, }); if (!confirmed) return; setIsReplanning(true); try { // Issue moves in parallel — onMoveTask is per-task, no bulk endpoint. const results = await Promise.allSettled( tasks.map((task) => onMoveTask(task.id, "triage" as ColumnType)), ); const failed = results.filter((r) => r.status === "rejected").length; const moved = results.length - failed; if (failed === 0) { addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to planning for replanning`, "success"); } else { addToast(`Moved ${moved} of ${results.length} tasks; ${failed} failed`, "error"); } } finally { setIsReplanning(false); } }, [tasks, onMoveTask, addToast, confirm]); const pauseEligibleTasks = useMemo( () => tasks.filter((task) => !task.paused && !task.assignedAgentId), [tasks], ); const pauseEligibleCount = pauseEligibleTasks.length; const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review"; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const handlePauseAll = useCallback(async () => { if (!onPauseTask) return; setIsMenuOpen(false); if (pauseEligibleCount === 0) return; const confirmed = await confirm({ title: "Stop All Tasks", message: `Stop all ${pauseEligibleCount} ${COLUMN_LABELS[column].toLowerCase()} task${pauseEligibleCount === 1 ? "" : "s"}?`, danger: true, }); if (!confirmed) return; setIsPausingAll(true); try { const results = await Promise.allSettled( pauseEligibleTasks.map((task) => onPauseTask(task.id)), ); const failed = results.filter((r) => r.status === "rejected").length; const paused = results.length - failed; if (failed === 0) { addToast(`Stopped ${paused} task${paused === 1 ? "" : "s"}`, "success"); } else { addToast(`Stopped ${paused} of ${results.length} tasks; ${failed} failed`, "error"); } } finally { setIsPausingAll(false); } }, [onPauseTask, pauseEligibleCount, column, pauseEligibleTasks, addToast, confirm]); const handleMoveAllToTodo = useCallback(async () => { setIsMenuOpen(false); if (tasks.length === 0) return; const confirmed = await confirm({ title: "Move All to Todo", message: `Move all ${tasks.length} ${COLUMN_LABELS[column].toLowerCase()} task${tasks.length === 1 ? "" : "s"} to Todo?`, }); if (!confirmed) return; const hasAnyProgress = tasks.some((task) => task.steps.some((step) => step.status !== "pending")); let preserveProgress = false; if (hasAnyProgress) { const keepProgress = await confirm({ title: "Preserve Progress?", message: "Some tasks have completed steps. Keep progress before moving to Todo?", confirmLabel: "Keep Progress", cancelLabel: "Reset Progress", }); if (keepProgress) { preserveProgress = true; } else { const resetProgress = await confirm({ title: "Reset Progress?", message: "Reset step progress for tasks before moving to Todo?", confirmLabel: "Reset Progress", cancelLabel: "Cancel Move", danger: true, }); if (!resetProgress) { return; } } } setIsMovingAllToTodo(true); try { const results = await Promise.allSettled( tasks.map((task) => onMoveTask(task.id, "todo", preserveProgress ? { preserveProgress: true } : undefined)), ); const failed = results.filter((r) => r.status === "rejected").length; const moved = results.length - failed; if (failed === 0) { addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to Todo`, "success"); } else { addToast(`Moved ${moved} of ${results.length} tasks to Todo; ${failed} failed`, "error"); } } finally { setIsMovingAllToTodo(false); } }, [tasks, column, onMoveTask, addToast, confirm]); const handleArchiveAll = useCallback(async () => { if (!onArchiveAllDone) return; if (tasks.length === 0) return; const confirmed = await confirm({ title: "Archive All Done", message: `Archive all ${tasks.length} done tasks?`, danger: true, }); 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, confirm]); return (

{COLUMN_LABELS[column]}

{tasks.length} {column === "in-review" && onToggleAutoMerge && ( )} {onNewTask && ( )} {column === "done" && onArchiveAllDone && ( )} {isArchived && onToggleCollapse && ( )} {hasColumnBulkActions && (
{isMenuOpen && (
{column === "todo" && ( )} {(column === "in-progress" || column === "in-review") && ( <> )}
)}
)}
{!isCollapsed &&

{COLUMN_DESCRIPTIONS[column]}

} {!isCollapsed && (
{column === "triage" && onQuickCreate && ( { const matchingTask = (allTasks ?? []).find((candidate) => candidate.id === taskId); if (matchingTask) { onOpenDetail(matchingTask); return; } if (typeof window !== "undefined") { window.location.hash = `#/tasks/${taskId}`; } }} /> )} {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";