import "./ListView.css"; import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react"; import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core"; import { batchUpdateTaskModels } from "../api"; import type { ModelInfo } from "../api"; import { QuickEntryBox } from "./QuickEntryBox"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { isTaskStuck } from "../utils/taskStuck"; import type { ToastType } from "../hooks/useToast"; import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getUnifiedTaskProgress } from "../utils/taskProgress"; 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"]); 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]; function readVisibleColumns(projectId?: string): Set { try { const saved = getScopedItem("kb-dashboard-list-columns", projectId); if (saved) { const parsed = JSON.parse(saved) as ListColumn[]; 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); } function readHideDoneTasks(projectId?: string): boolean { try { const saved = getScopedItem("kb-dashboard-hide-done", projectId); if (saved !== null) { return saved === "true"; } } catch { // Invalid localStorage data - fall through to default } return false; } function readCollapsedSections(projectId?: string): Set { try { const saved = getScopedItem("kb-dashboard-list-collapsed", projectId); if (saved) { const parsed = JSON.parse(saved) as Column[]; 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(); } function readSelectedTaskIds(projectId?: string): Set { try { const saved = getScopedItem("kb-dashboard-selected-tasks", projectId); if (saved) { const parsed = JSON.parse(saved) as string[]; return new Set(parsed); } } catch { // Invalid localStorage data - fall through to default } return new Set(); } interface ListViewProps { tasks: Task[]; onMoveTask: (id: string, column: Column) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onNewTask?: () => void; onQuickCreate?: (input: TaskCreateInput) => Promise; availableModels?: ModelInfo[]; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; /** * 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; /** * Called when tasks are updated (e.g., after bulk model update). * Allows parent to refresh task list or handle optimistically. */ onTasksUpdated?: (updatedTasks: Task[]) => void; /** Project ID for multi-project context (optional) */ projectId?: string; /** Project name for display (optional) */ projectName?: string; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; /** External search query from header search (defaults to "") */ searchQuery?: string; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; } function shouldShowTaskProgress(task: Task): boolean { return task.status === "executing" || task.column === "in-progress"; } function getTaskProgress(task: Task): { label: string; percent: number; hasProgress: boolean } { const progress = getUnifiedTaskProgress(task); if (progress.total === 0 || !shouldShowTaskProgress(task)) { return { label: "-", percent: 0, hasProgress: false }; } return { label: `${progress.completed}/${progress.total}`, percent: (progress.completed / progress.total) * 100, hasProgress: true, }; } export function ListView({ tasks, onMoveTask, onOpenDetail, addToast, globalPaused, onNewTask, onQuickCreate, availableModels, favoriteProviders = [], favoriteModels = [], onToggleFavorite, onToggleModelFavorite, onPlanningMode, onSubtaskBreakdown, onTasksUpdated, projectId, projectName: _projectName, taskStuckTimeoutMs, searchQuery = "", lastFetchTimeMs, }: ListViewProps) { const [sortField, setSortField] = useState("id"); const [sortDirection, setSortDirection] = useState("desc"); const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; // Column visibility state - initialize from localStorage or default to all columns const [visibleColumns, setVisibleColumns] = useState>(() => readVisibleColumns(projectId)); // Hide done tasks state - initialize from localStorage const [hideDoneTasks, setHideDoneTasks] = useState(() => readHideDoneTasks(projectId)); // Collapsed sections state - initialize from localStorage const [collapsedSections, setCollapsedSections] = useState>(() => readCollapsedSections(projectId), ); // Persist column visibility changes to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-list-columns", JSON.stringify([...visibleColumns]), projectId); } }, [projectId, visibleColumns]); // Persist hide done tasks state to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-hide-done", hideDoneTasks.toString(), projectId); } }, [hideDoneTasks, projectId]); // Persist collapsed sections state to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-list-collapsed", JSON.stringify([...collapsedSections]), projectId); } }, [collapsedSections, projectId]); // Column dropdown state const [columnDropdownOpen, setColumnDropdownOpen] = useState(false); const columnDropdownRef = useRef(null); // Selection state - initialize from localStorage const [selectedTaskIds, setSelectedTaskIds] = useState>(() => readSelectedTaskIds(projectId)); useEffect(() => { setVisibleColumns(readVisibleColumns(projectId)); setHideDoneTasks(readHideDoneTasks(projectId)); setCollapsedSections(readCollapsedSections(projectId)); setSelectedTaskIds(readSelectedTaskIds(projectId)); }, [projectId]); // Persist selection to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-selected-tasks", JSON.stringify([...selectedTaskIds]), projectId); } }, [projectId, selectedTaskIds]); // Toggle task selection const toggleTaskSelection = useCallback((taskId: string) => { setSelectedTaskIds((prev) => { const next = new Set(prev); if (next.has(taskId)) { next.delete(taskId); } else { next.add(taskId); } return next; }); }, []); // Clear selection const clearSelection = useCallback(() => { setSelectedTaskIds(new Set()); }, []); // 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 = searchQuery ? tasks.filter( (t) => t.id.toLowerCase().includes(searchQuery.toLowerCase()) || (t.title && t.title.toLowerCase().includes(searchQuery.toLowerCase())) || t.description.toLowerCase().includes(searchQuery.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, searchQuery, 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]); // Selection logic that depends on groupedTasks (must be after groupedTasks definition) // Toggle all visible tasks const toggleSelectAll = useCallback(() => { const visibleTaskIds = Object.values(groupedTasks) .flat() .filter((t) => t.column !== "archived") // Can't bulk edit archived .map((t) => t.id); setSelectedTaskIds((prev) => { const allSelected = visibleTaskIds.every((id) => prev.has(id)); if (allSelected) { // Deselect all visible const next = new Set(prev); visibleTaskIds.forEach((id) => next.delete(id)); return next; } else { // Select all visible return new Set([...prev, ...visibleTaskIds]); } }); }, [groupedTasks]); // Check if all visible tasks are selected const isSelectAll = useMemo(() => { const visibleTaskIds = Object.values(groupedTasks) .flat() .filter((t) => t.column !== "archived"); if (visibleTaskIds.length === 0) return false; return visibleTaskIds.every((t) => selectedTaskIds.has(t.id)); }, [groupedTasks, selectedTaskIds]); // Check if some (but not all) visible tasks are selected const isSelectIndeterminate = useMemo(() => { const visibleTaskIds = Object.values(groupedTasks) .flat() .filter((t) => t.column !== "archived"); if (visibleTaskIds.length === 0) return false; const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length; return selectedCount > 0 && selectedCount < visibleTaskIds.length; }, [groupedTasks, selectedTaskIds]); // Bulk edit state and handlers (must be after groupedTasks and clearSelection definition) const [executorModel, setExecutorModel] = useState("__no_change__"); const [validatorModel, setValidatorModel] = useState("__no_change__"); const [isApplying, setIsApplying] = useState(false); // Handle apply bulk model update const handleApplyBulkUpdate = useCallback(async () => { if (selectedTaskIds.size === 0) return; const taskIds = Array.from(selectedTaskIds).filter((id) => { const task = tasks.find((t) => t.id === id); return task && task.column !== "archived"; }); if (taskIds.length === 0) { addToast("No valid tasks to update (archived tasks cannot be modified)", "error"); return; } // Build payload - only include fields that changed from "__no_change__" const payload: { taskIds: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; } = { taskIds }; if (executorModel !== "__no_change__") { if (executorModel === "") { // "Use default" - clear override payload.modelProvider = null; payload.modelId = null; } else { const slashIdx = executorModel.indexOf("/"); if (slashIdx !== -1) { payload.modelProvider = executorModel.slice(0, slashIdx); payload.modelId = executorModel.slice(slashIdx + 1); } } } if (validatorModel !== "__no_change__") { if (validatorModel === "") { // "Use default" - clear override payload.validatorModelProvider = null; payload.validatorModelId = null; } else { const slashIdx = validatorModel.indexOf("/"); if (slashIdx !== -1) { payload.validatorModelProvider = validatorModel.slice(0, slashIdx); payload.validatorModelId = validatorModel.slice(slashIdx + 1); } } } // Check if any changes were made if (Object.keys(payload).length === 1) { addToast("No changes to apply", "info"); return; } setIsApplying(true); try { const result = await batchUpdateTaskModels( payload.taskIds, payload.modelProvider, payload.modelId, payload.validatorModelProvider, payload.validatorModelId, undefined, undefined, projectId, ); // Optimistically update parent with returned tasks if (onTasksUpdated && result.updated.length > 0) { onTasksUpdated(result.updated); } addToast(`Updated ${result.count} task${result.count === 1 ? "" : "s"}`, "success"); // Reset state clearSelection(); setExecutorModel("__no_change__"); setValidatorModel("__no_change__"); } catch (err) { addToast(getErrorMessage(err) || "Failed to update models", "error"); } finally { setIsApplying(false); } }, [selectedTaskIds, tasks, executorModel, validatorModel, projectId, addToast, clearSelection, onTasksUpdated]); const handleRowClick = useCallback( (task: Task) => { onOpenDetail(task); }, [onOpenDetail] ); 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) { addToast(getErrorMessage(err), "error"); } }, [onMoveTask, addToast] ); const getSortIcon = (field: SortField) => { if (sortField !== field) return ; return sortDirection === "asc" ? ( ) : ( ); }; return (
{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 && ( )}
{selectedTaskIds.size > 0 && (
{selectedTaskIds.size} selected
)} {/* Bulk Edit Toolbar */} {selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
Bulk Edit Models:
)} {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} projectId={projectId} autoExpand={false} favoriteProviders={favoriteProviders} favoriteModels={favoriteModels} onToggleFavorite={onToggleFavorite} onToggleModelFavorite={onToggleModelFavorite} />
{filteredCount === 0 ? (
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
) : isMobile ? (
{COLUMNS.map((column) => { if (selectedColumn && column !== selectedColumn) return null; if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null; const columnTasks = groupedTasks[column]; const isEmpty = columnTasks.length === 0; if (searchQuery && isEmpty) return null; const isCollapsed = collapsedSections.has(column); return (
toggleSection(column)} aria-expanded={!isCollapsed} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSection(column); } }} > {COLUMN_LABELS[column]} {columnTasks.length}
{!isCollapsed && ( <> {isEmpty ? (
No tasks
) : ( columnTasks.map((task) => { const isFailed = task.status === "failed"; const isPaused = task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = !globalPaused && !isFailed && !isPaused && !isStuckState && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const hasStatus = typeof task.status === "string" && task.status.trim().length > 0; const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0); const taskProgress = getTaskProgress(task); const hasProgress = taskProgress.hasProgress; const isSelectionMode = selectedTaskIds.size > 0; return (
handleRowClick(task)} data-id={task.id} > {isSelectionMode && ( )}
{task.id} {task.executionMode === "fast" && ( Fast )} {isStuckState ? ( Stuck ) : hasStatus ? ( {task.status} ) : null}
{task.title || task.description}
{(hasDependencies || hasProgress) && (
{hasDependencies && ( {task.dependencies.length} )} {hasProgress && (
{taskProgress.label}
)}
)}
); }) )} )} ); })}
) : ( {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 (searchQuery && 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 isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = !globalPaused && !isFailed && !isPaused && !isStuckState && (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") && ( )} ); }) )} )} ); })}
{ if (el) el.indeterminate = isSelectIndeterminate; }} onChange={toggleSelectAll} aria-label="Select all visible tasks" /> 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
{ e.stopPropagation(); toggleTaskSelection(task.id); }} onClick={(e) => e.stopPropagation()} disabled={task.column === "archived"} aria-label={`Select ${task.id}`} /> {task.id}
{task.executionMode === "fast" && ( Fast )} {task.title || task.description}
{isStuckState ? ( Stuck ) : task.status ? ( {task.status} ) : ( - )} {COLUMN_LABELS[task.column]} {task.dependencies && task.dependencies.length > 0 ? ( {task.dependencies.length} ) : ( "-" )} {(() => { const taskProgress = getTaskProgress(task); if (!taskProgress.hasProgress) return "-"; return (
{taskProgress.label}
); })()}
)}
); }