import { memo, useMemo, useState, useCallback, useEffect } from "react"; import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@kb/core"; import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core"; import { TaskCard } from "./TaskCard"; import { WorktreeGroup } from "./WorktreeGroup"; import { QuickEntryBox } from "./QuickEntryBox"; 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[]; maxConcurrent: number; onMoveTask: (id: string, column: ColumnType) => Promise; onOpenDetail: (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; 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; onOpenFilesForTask?: (taskId: string) => void; } function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: 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; const shouldPaginate = !isArchived && 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: any) { addToast(err.message, "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: any) { addToast(err.message || "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";