import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react"; import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS } from "@fusion/core"; import { fetchTaskDetail } from "../api"; import type { ModelInfo } from "../api"; import { QuickEntryBox } from "./QuickEntryBox"; import type { ToastType } from "../hooks/useToast"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", todo: "var(--todo)", "in-progress": "var(--in-progress)", "in-review": "var(--in-review)", done: "var(--done)", archived: "var(--text-secondary)", }; const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]); type SortField = "id" | "title" | "status" | "column"; type SortDirection = "asc" | "desc"; // Column visibility types const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "dependencies", "progress"] as const; type ListColumn = typeof ALL_LIST_COLUMNS[number]; interface ListViewProps { tasks: Task[]; onMoveTask: (id: string, column: Column) => Promise; onOpenDetail: (task: TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onNewTask?: () => void; onQuickCreate?: (input: TaskCreateInput) => Promise; availableModels?: ModelInfo[]; /** * Called when the user clicks the "Plan" button in the quick entry box. */ onPlanningMode?: (initialPlan: string) => void; /** * Called when the user clicks the "Subtask" button in the quick entry box. */ onSubtaskBreakdown?: (description: string) => void; } function getStepProgress(steps: TaskStep[]): string { if (steps.length === 0) return "-"; const done = steps.filter((s) => s.status === "done").length; return `${done}/${steps.length}`; } function getStepProgressPercent(steps: TaskStep[]): number { if (steps.length === 0) return 0; const done = steps.filter((s) => s.status === "done").length; return (done / steps.length) * 100; } export function ListView({ tasks, onMoveTask, onOpenDetail, addToast, globalPaused, onNewTask, onQuickCreate, availableModels, onPlanningMode, onSubtaskBreakdown, }: ListViewProps) { const [sortField, setSortField] = useState("id"); const [sortDirection, setSortDirection] = useState("desc"); const [filter, setFilter] = useState(""); const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); // Column visibility state - initialize from localStorage or default to all columns const [visibleColumns, setVisibleColumns] = useState>(() => { if (typeof window !== "undefined") { try { const saved = localStorage.getItem("kb-dashboard-list-columns"); if (saved) { const parsed = JSON.parse(saved) as ListColumn[]; // Validate that all saved columns are valid ListColumn values const validColumns = parsed.filter((col): col is ListColumn => ALL_LIST_COLUMNS.includes(col as ListColumn) ); if (validColumns.length > 0) { return new Set(validColumns); } } } catch { // Invalid localStorage data - fall through to default } } return new Set(ALL_LIST_COLUMNS); }); // Hide done tasks state - initialize from localStorage const [hideDoneTasks, setHideDoneTasks] = useState(() => { if (typeof window !== "undefined") { try { const saved = localStorage.getItem("kb-dashboard-hide-done"); if (saved !== null) { return saved === "true"; } } catch { // Invalid localStorage data - fall through to default } } return false; // Default: show done tasks }); // Collapsed sections state - initialize from localStorage const [collapsedSections, setCollapsedSections] = useState>(() => { if (typeof window !== "undefined") { try { const saved = localStorage.getItem("kb-dashboard-list-collapsed"); if (saved) { const parsed = JSON.parse(saved) as Column[]; // Validate that all saved columns are valid Column values const validColumns = parsed.filter((col): col is Column => COLUMNS.includes(col as Column) ); if (validColumns.length > 0) { return new Set(validColumns); } } } catch { // Invalid localStorage data - fall through to default } } return new Set(); // Default: all sections expanded }); // Persist column visibility changes to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("kb-dashboard-list-columns", JSON.stringify([...visibleColumns])); } }, [visibleColumns]); // Persist hide done tasks state to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("kb-dashboard-hide-done", hideDoneTasks.toString()); } }, [hideDoneTasks]); // Persist collapsed sections state to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("kb-dashboard-list-collapsed", JSON.stringify([...collapsedSections])); } }, [collapsedSections]); // Column dropdown state const [columnDropdownOpen, setColumnDropdownOpen] = useState(false); const columnDropdownRef = useRef(null); // Toggle a column's visibility const toggleColumn = useCallback((column: ListColumn) => { setVisibleColumns((prev) => { const next = new Set(prev); if (next.has(column)) { // Prevent hiding the last visible column if (next.size > 1) { next.delete(column); } } else { next.add(column); } return next; }); }, []); // Close dropdown when clicking outside useEffect(() => { if (!columnDropdownOpen) return; const handleClickOutside = (e: MouseEvent) => { if (columnDropdownRef.current && !columnDropdownRef.current.contains(e.target as Node)) { setColumnDropdownOpen(false); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { setColumnDropdownOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [columnDropdownOpen]); // Column display labels const COLUMN_LABELS_MAP: Record = { id: "ID", title: "Title", status: "Status", column: "Column", dependencies: "Dependencies", progress: "Progress", }; const handleSort = useCallback((field: SortField) => { if (sortField === field) { setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); } else { setSortField(field); setSortDirection("asc"); } }, [sortField]); const handleColumnFilter = useCallback((column: Column) => { setSelectedColumn((prev) => (prev === column ? null : column)); }, []); const toggleSection = useCallback((column: Column) => { setCollapsedSections((prev) => { const next = new Set(prev); if (next.has(column)) { next.delete(column); } else { next.add(column); } return next; }); }, []); const clearColumnFilter = useCallback(() => { setSelectedColumn(null); }, []); const groupedTasks = useMemo(() => { // First apply text filter let filtered = filter ? tasks.filter( (t) => t.id.toLowerCase().includes(filter.toLowerCase()) || (t.title && t.title.toLowerCase().includes(filter.toLowerCase())) || t.description.toLowerCase().includes(filter.toLowerCase()) ) : [...tasks]; // Then filter out done and archived tasks if hideDoneTasks is enabled // BUT only when no specific column is selected (strict hide semantics) if (hideDoneTasks && !selectedColumn) { filtered = filtered.filter((t) => t.column !== "done" && t.column !== "archived"); } // Then apply column filter if selected const columnFiltered = selectedColumn ? filtered.filter((t) => t.column === selectedColumn) : filtered; const sorted = [...columnFiltered].sort((a, b) => { let comparison = 0; switch (sortField) { case "id": comparison = a.id.localeCompare(b.id); break; case "title": comparison = (a.title || a.description).localeCompare(b.title || b.description); break; case "status": comparison = (a.status || "").localeCompare(b.status || ""); break; case "column": comparison = a.column.localeCompare(b.column); break; } return sortDirection === "asc" ? comparison : -comparison; }); // Group by column while preserving sort order within each group const groups: Record = { triage: [], todo: [], "in-progress": [], "in-review": [], done: [], archived: [] }; sorted.forEach(task => groups[task.column].push(task)); return groups; }, [tasks, filter, sortField, sortDirection, hideDoneTasks, selectedColumn]); // Calculate total filtered count from groups const filteredCount = useMemo(() => { return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0); }, [groupedTasks]); // Calculate done and archived task counts for stats display const completedTaskCount = useMemo(() => { return tasks.filter((t) => t.column === "done" || t.column === "archived").length; }, [tasks]); // Calculate hidden done+archived tasks count const hiddenCompletedCount = useMemo(() => { if (!hideDoneTasks) return 0; return completedTaskCount; }, [hideDoneTasks, completedTaskCount]); const handleRowClick = useCallback( async (task: Task) => { try { const detail = await fetchTaskDetail(task.id); onOpenDetail(detail); } catch (err: any) { addToast("Failed to load task details", "error"); } }, [onOpenDetail, addToast] ); const handleDragStart = useCallback( (e: React.DragEvent, task: Task) => { if (task.paused) { e.preventDefault(); return; } e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.effectAllowed = "move"; setDraggingTaskId(task.id); }, [] ); const handleDragEnd = useCallback(() => { setDraggingTaskId(null); setDragOverColumn(null); }, []); const handleColumnDragOver = useCallback( (e: React.DragEvent, column: Column) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverColumn(column); }, [] ); const handleColumnDragLeave = useCallback(() => { setDragOverColumn(null); }, []); const handleColumnDrop = useCallback( async (e: React.DragEvent, column: Column) => { e.preventDefault(); setDragOverColumn(null); const taskId = e.dataTransfer.getData("text/plain"); if (!taskId) return; // Prevent dropping into archived column if (column === "archived") { addToast("Tasks can only be archived via the archive button", "error"); return; } try { await onMoveTask(taskId, column); } catch (err: any) { addToast(err.message, "error"); } }, [onMoveTask, addToast] ); const getSortIcon = (field: SortField) => { if (sortField !== field) return ; return sortDirection === "asc" ? ( ) : ( ); }; return (
setFilter(e.target.value)} className="filter-input" /> {filter && ( )}
{columnDropdownOpen && (
{ALL_LIST_COLUMNS.map((column) => { const isVisible = visibleColumns.has(column); const isLastVisible = isVisible && visibleColumns.size === 1; return ( ); })}
)}
{selectedColumn ? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}` : `${filteredCount} of ${tasks.length} tasks`} {hiddenCompletedCount > 0 && !selectedColumn && ( ({hiddenCompletedCount} hidden) )} {selectedColumn && ( )}
{onNewTask ? ( ) : null}
{COLUMNS.map((column) => { const totalCount = tasks.filter((t) => t.column === column).length; const isCompletedColumn = column === "done" || column === "archived"; const visibleCount = hideDoneTasks && isCompletedColumn ? 0 : totalCount; const showPartial = hideDoneTasks && isCompletedColumn && totalCount > 0; return (
handleColumnFilter(column)} onDragOver={(e) => handleColumnDragOver(e, column)} onDragLeave={handleColumnDragLeave} onDrop={(e) => handleColumnDrop(e, column)} data-column={column} > {COLUMN_LABELS[column]} {showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
); })}
addToast("Task creation not available", "error"))} addToast={addToast} tasks={tasks} availableModels={availableModels} onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} />
{filteredCount === 0 ? (
{filter ? "No tasks match your filter" : "No tasks yet"}
) : ( {visibleColumns.has("id") && ( )} {visibleColumns.has("title") && ( )} {visibleColumns.has("status") && ( )} {visibleColumns.has("column") && ( )} {visibleColumns.has("dependencies") && ( )} {visibleColumns.has("progress") && ( )} {COLUMNS.map((column) => { // When column filter is active, only show the selected column if (selectedColumn && column !== selectedColumn) return null; // Skip done and archived column sections when hideDoneTasks is enabled (unless it's the selected column) if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null; const columnTasks = groupedTasks[column]; const isEmpty = columnTasks.length === 0; // When text filtering, hide empty sections entirely if (filter && isEmpty) return null; const isCollapsed = collapsedSections.has(column); return ( {/* Section Header */} toggleSection(column)} aria-expanded={!isCollapsed} > {/* Task Rows - only render when not collapsed */} {!isCollapsed && ( <> {isEmpty ? ( ) : ( columnTasks.map((task) => { const isFailed = task.status === "failed"; const isPaused = task.paused === true; const isAgentActive = !globalPaused && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const isDragging = draggingTaskId === task.id; return ( handleRowClick(task)} draggable={!isPaused} onDragStart={(e) => handleDragStart(e, task)} onDragEnd={handleDragEnd} data-id={task.id} > {visibleColumns.has("id") && ( )} {visibleColumns.has("title") && ( )} {visibleColumns.has("status") && ( )} {visibleColumns.has("column") && ( )} {visibleColumns.has("dependencies") && ( )} {visibleColumns.has("progress") && ( )} ); }) )} )} ); })}
handleSort("id")}> ID {getSortIcon("id")} handleSort("title")}> Title {getSortIcon("title")} handleSort("status")}> Status {getSortIcon("status")} handleSort("column")}> Column {getSortIcon("column")} DependenciesProgress
{COLUMN_LABELS[column]} {columnTasks.length}
No tasks
{task.id} {task.title || task.description} {task.status ? ( {task.status} ) : ( - )} {COLUMN_LABELS[task.column]} {task.dependencies && task.dependencies.length > 0 ? ( {task.dependencies.length} ) : ( "-" )} {task.steps.length > 0 ? (
{getStepProgress(task.steps)}
) : ( "-" )}
)}
); }