import "./ListView.css"; import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react"; import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api"; import { TaskDetailContent } from "./TaskDetailModal"; import type { ModelInfo, NodeInfo } from "../api"; import { QuickEntryBox } from "./QuickEntryBox"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; import { isTaskStuck } from "../utils/taskStuck"; import type { ToastType } from "../hooks/useToast"; import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict } from "../utils/taskDelete"; 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-dim)", }; const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); type SortField = "title" | "status" | "column" | "retries"; function getTaskStatusLabel(status: string): string { if (status === "merging-fix") return "Merging fixes…"; return status; } type SortDirection = "asc" | "desc"; // Column visibility types const ALL_LIST_COLUMNS = ["title", "status", "column", "retries", "dependencies", "progress"] as const; const DEFAULT_LIST_COLUMNS = ["title", "status", "column", "retries"] as const; type ListColumn = typeof ALL_LIST_COLUMNS[number]; function getNodeStatusLabel(status: NodeInfo["status"]): string { if (status === "online") return "Online"; if (status === "connecting") return "Connecting"; if (status === "error") return "Error"; return "Offline"; } function getNodeStatusSymbol(status: NodeInfo["status"]): string { if (status === "online") return "●"; if (status === "connecting") return "◐"; if (status === "error") return "✕"; return "○"; } 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(DEFAULT_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 readStaleOnlyFilter(projectId?: string): boolean { try { const saved = getScopedItem("kb-dashboard-stale-only-filter", 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(); } function readSelectedTaskId(projectId?: string): string | null { try { const saved = getScopedItem("kb-dashboard-list-selected-task", projectId); if (typeof saved === "string" && saved.trim().length > 0) { return saved; } } catch { // Invalid localStorage data - fall through to default } return null; } function readSidebarWidth(projectId?: string): number { const fallbackWidth = 400; try { const saved = getScopedItem("kb-dashboard-list-sidebar-width", projectId); if (!saved) return fallbackWidth; const parsed = Number(saved); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } catch { // Invalid localStorage data - fall through to default } return fallbackWidth; } const LIST_SIDEBAR_MIN_WIDTH = 280; const LIST_SIDEBAR_MAX_RATIO = 0.65; const LIST_SIDEBAR_KEYBOARD_STEP = 16; function getSidebarMaxWidth(containerWidth: number): number { return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO); } function clampSidebarWidth(width: number, containerWidth: number): number { const maxWidth = getSidebarMaxWidth(containerWidth); return Math.min(Math.max(width, LIST_SIDEBAR_MIN_WIDTH), maxWidth); } interface ListViewProps { tasks: Task[]; onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onRetryTask?: (id: string) => Promise; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise; onPauseTask?: (id: string) => Promise; onUnpauseTask?: (id: string) => Promise; onArchiveTask?: (id: string) => Promise; onMergeTask: (id: string) => Promise; onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => 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; prAuthAvailable?: boolean; } 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, onRetryTask, onDeleteTask, onPauseTask, onUnpauseTask, onArchiveTask, onMergeTask, onResetTask, onDuplicateTask, onOpenDetail, addToast, globalPaused, onNewTask, onQuickCreate, availableModels, favoriteProviders = [], favoriteModels = [], onToggleFavorite, onToggleModelFavorite, onPlanningMode, onSubtaskBreakdown, onTasksUpdated, projectId, projectName: _projectName, taskStuckTimeoutMs, searchQuery = "", lastFetchTimeMs, prAuthAvailable, }: ListViewProps) { const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState("asc"); const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const { confirm } = useConfirm(); // Column visibility state - initialize from localStorage or reduced default columns const [visibleColumns, setVisibleColumns] = useState>(() => readVisibleColumns(projectId)); // Hide done tasks state - initialize from localStorage const [hideDoneTasks, setHideDoneTasks] = useState(() => readHideDoneTasks(projectId)); const [staleOnlyFilter, setStaleOnlyFilter] = useState(() => readStaleOnlyFilter(projectId)); const [stalePausedReviewOnlyFilter, setStalePausedReviewOnlyFilter] = useState(false); // 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 stale-only filter state to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-stale-only-filter", staleOnlyFilter.toString(), projectId); } }, [projectId, staleOnlyFilter]); // Persist collapsed sections state to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-list-collapsed", JSON.stringify([...collapsedSections]), projectId); } }, [collapsedSections, projectId]); const [viewOptionsOpen, setViewOptionsOpen] = useState(false); // Selection state - initialize from localStorage const [bulkEditEnabled, setBulkEditEnabled] = useState(false); const [selectedTaskIds, setSelectedTaskIds] = useState>(() => readSelectedTaskIds(projectId)); const [selectedTaskId, setSelectedTaskId] = useState(() => readSelectedTaskId(projectId)); const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(() => readSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const splitSidebarRef = useRef(null); useEffect(() => { setVisibleColumns(readVisibleColumns(projectId)); setHideDoneTasks(readHideDoneTasks(projectId)); setStaleOnlyFilter(readStaleOnlyFilter(projectId)); setStalePausedReviewOnlyFilter(false); setCollapsedSections(readCollapsedSections(projectId)); setSelectedTaskIds(readSelectedTaskIds(projectId)); const persistedSelection = readSelectedTaskId(projectId); setSelectedTaskId(persistedSelection); setSelectedTaskSnapshot( persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null, ); setSidebarWidth(readSidebarWidth(projectId)); }, [projectId, tasks]); // Persist selection to localStorage useEffect(() => { if (typeof window !== "undefined") { setScopedItem("kb-dashboard-selected-tasks", JSON.stringify([...selectedTaskIds]), projectId); } }, [projectId, selectedTaskIds]); useEffect(() => { if (typeof window === "undefined") return; if (selectedTaskId) { setScopedItem("kb-dashboard-list-selected-task", selectedTaskId, projectId); return; } removeScopedItem("kb-dashboard-list-selected-task", projectId); }, [projectId, selectedTaskId]); useEffect(() => { if (!selectedTaskId) { setSelectedTaskSnapshot(null); return; } const liveTask = tasks.find((task) => task.id === selectedTaskId); if (!liveTask) return; setSelectedTaskSnapshot((previous) => { if (!previous || previous.id !== selectedTaskId) { return liveTask; } return { ...previous, ...liveTask }; }); }, [selectedTaskId, tasks]); useEffect(() => { if (isMobile || typeof ResizeObserver === "undefined") return; const container = splitLayoutRef.current; if (!container) return; const applyClamp = () => { // Keep width valid when viewport/container size changes. const clamped = clampSidebarWidth(sidebarWidth, container.clientWidth); if (clamped !== sidebarWidth) { setSidebarWidth(clamped); } }; applyClamp(); const observer = new ResizeObserver(applyClamp); observer.observe(container); return () => observer.disconnect(); }, [isMobile, sidebarWidth]); useEffect(() => { if (isMobile || typeof ResizeObserver === "undefined") return; const sidebar = splitSidebarRef.current; const container = splitLayoutRef.current; if (!sidebar || !container) return; let saveTimer: ReturnType | null = null; let lastSavedWidth = sidebar.offsetWidth; const observer = new ResizeObserver(() => { const nextWidth = clampSidebarWidth(sidebar.offsetWidth, container.clientWidth); if (nextWidth === lastSavedWidth) return; lastSavedWidth = nextWidth; if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { try { setScopedItem("kb-dashboard-list-sidebar-width", String(nextWidth), projectId); } catch { // localStorage persistence is best-effort. } }, 200); }); observer.observe(sidebar); return () => { observer.disconnect(); if (saveTimer) clearTimeout(saveTimer); }; }, [isMobile, projectId]); const toggleBulkEdit = useCallback(() => { setBulkEditEnabled((prev) => { if (prev) { setSelectedTaskIds(new Set()); } return !prev; }); }, []); // 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; }); }, []); // Column display labels const COLUMN_LABELS_MAP: Record = { title: "Title", status: "Status", column: "Column", dependencies: "Dependencies", progress: "Progress", retries: "Retries", }; const handleSort = useCallback((field: SortField) => { if (sortField === field) { setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); return; } 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 stale-only filter if selected if (staleOnlyFilter) { filtered = filtered.filter((t) => t.ageStaleness != null); } if (stalePausedReviewOnlyFilter) { filtered = filtered.filter((t) => t.stalePausedReview != null); } // Then apply column filter if selected const columnFiltered = selectedColumn ? filtered.filter((t) => t.column === selectedColumn) : filtered; const groups: Record = { triage: [], todo: [], "in-progress": [], "in-review": [], done: [], archived: [], }; columnFiltered.forEach((task) => { const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN; groups[column].push(task); }); for (const column of COLUMNS) { if (!sortField) { groups[column] = sortTasksForDisplayColumn(groups[column], column); continue; } groups[column] = [...groups[column]].sort((a, b) => { let comparison = 0; switch (sortField) { 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; case "retries": comparison = (a.retrySummary?.total ?? 0) - (b.retrySummary?.total ?? 0); break; } return sortDirection === "asc" ? comparison : -comparison; }); } return groups; }, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, stalePausedReviewOnlyFilter, 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 [nodeOverride, setNodeOverride] = useState("__no_change__"); const [availableNodes, setAvailableNodes] = useState([]); const [isLoadingNodes, setIsLoadingNodes] = useState(false); const selectedOverrideNode = useMemo( () => (nodeOverride && nodeOverride !== "__no_change__" ? availableNodes.find((node) => node.id === nodeOverride) : undefined), [availableNodes, nodeOverride], ); const [isApplying, setIsApplying] = useState(false); useEffect(() => { if (selectedTaskIds.size === 0) return; let isCancelled = false; const loadNodes = async () => { setIsLoadingNodes(true); try { const nodes = await fetchNodes(); if (!isCancelled) { setAvailableNodes(nodes); } } catch (err) { console.error("Failed to fetch nodes for bulk edit", err); if (!isCancelled) { setAvailableNodes([]); } } finally { if (!isCancelled) { setIsLoadingNodes(false); } } }; void loadNodes(); return () => { isCancelled = true; }; }, [selectedTaskIds.size]); // Handle apply bulk model update const handleBulkDelete = useCallback(async () => { if (selectedTaskIds.size === 0) return; const selectedTasks = Array.from(selectedTaskIds) .map((id) => tasks.find((task) => task.id === id)) .filter((task): task is Task => Boolean(task)); const archivedTasks = selectedTasks.filter((task) => task.column === "archived"); const deletableTasks = selectedTasks.filter((task) => task.column !== "archived"); if (deletableTasks.length === 0) { addToast("No selected tasks can be deleted (archived tasks are excluded)", "error"); return; } const confirmed = await confirm({ title: "Delete Selected Tasks", message: `Delete ${deletableTasks.length} selected task${deletableTasks.length === 1 ? "" : "s"}?`, confirmLabel: "Delete", cancelLabel: "Cancel", danger: true, }); if (!confirmed) return; setIsApplying(true); const deletedIds: string[] = []; const failedIds: string[] = []; const skippedIds = archivedTasks.map((task) => task.id); try { for (const task of deletableTasks) { try { await onDeleteTask(task.id); deletedIds.push(task.id); } catch (err) { const conflict = extractDependencyDeleteConflict(err); if (!conflict) { failedIds.push(task.id); continue; } const forceDelete = await confirm({ title: "Force Delete Task", message: `Task ${task.id} has dependents: ${conflict.dependentIds.join(", ")}. Remove dependency references and force delete?`, confirmLabel: "Force Delete", cancelLabel: "Skip", danger: true, }); if (!forceDelete) { failedIds.push(task.id); continue; } try { await onDeleteTask(task.id, { removeDependencyReferences: true }); deletedIds.push(task.id); } catch { failedIds.push(task.id); } } } } finally { setIsApplying(false); } if (deletedIds.length > 0) { setSelectedTaskIds((previous) => { const next = new Set(previous); for (const id of deletedIds) { next.delete(id); } return next; }); } addToast( `Deleted ${deletedIds.length} task${deletedIds.length === 1 ? "" : "s"} · ${skippedIds.length} archived skipped · ${failedIds.length} failed`, failedIds.length > 0 ? "error" : "success", ); }, [addToast, confirm, onDeleteTask, selectedTaskIds, tasks]); const handleBulkPause = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onPauseTask) { addToast("Pause action is unavailable", "error"); return; } const selectedTasks = Array.from(selectedTaskIds) .map((id) => tasks.find((task) => task.id === id)) .filter((task): task is Task => Boolean(task)); const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused !== true); const skippedCount = selectedTasks.length - actionableTasks.length; if (actionableTasks.length === 0) { addToast("No selected tasks can be paused", "error"); return; } setIsApplying(true); const pausedIds: string[] = []; const failedIds: string[] = []; try { for (const task of actionableTasks) { try { await onPauseTask(task.id); pausedIds.push(task.id); } catch { failedIds.push(task.id); } } } finally { setIsApplying(false); } if (pausedIds.length > 0) { setSelectedTaskIds((previous) => { const next = new Set(previous); for (const id of pausedIds) { next.delete(id); } return next; }); } addToast( `Paused ${pausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`, failedIds.length > 0 ? "error" : "success", ); }, [addToast, onPauseTask, selectedTaskIds, tasks]); const handleBulkUnpause = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onUnpauseTask) { addToast("Unpause action is unavailable", "error"); return; } const selectedTasks = Array.from(selectedTaskIds) .map((id) => tasks.find((task) => task.id === id)) .filter((task): task is Task => Boolean(task)); const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused === true); const skippedCount = selectedTasks.length - actionableTasks.length; if (actionableTasks.length === 0) { addToast("No selected tasks can be unpaused", "error"); return; } setIsApplying(true); const unpausedIds: string[] = []; const failedIds: string[] = []; try { for (const task of actionableTasks) { try { await onUnpauseTask(task.id); unpausedIds.push(task.id); } catch { failedIds.push(task.id); } } } finally { setIsApplying(false); } if (unpausedIds.length > 0) { setSelectedTaskIds((previous) => { const next = new Set(previous); for (const id of unpausedIds) { next.delete(id); } return next; }); } addToast( `Unpaused ${unpausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`, failedIds.length > 0 ? "error" : "success", ); }, [addToast, onUnpauseTask, selectedTaskIds, tasks]); const handleBulkArchive = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onArchiveTask) { addToast("Archive action is unavailable", "error"); return; } const selectedTasks = Array.from(selectedTaskIds) .map((id) => tasks.find((task) => task.id === id)) .filter((task): task is Task => Boolean(task)); const actionableTasks = selectedTasks.filter((task) => task.column === "done"); const skippedCount = selectedTasks.length - actionableTasks.length; if (actionableTasks.length === 0) { addToast("No selected tasks can be archived (only done tasks)", "error"); return; } const confirmed = await confirm({ title: "Archive Selected Tasks", message: `Archive ${actionableTasks.length} selected task${actionableTasks.length === 1 ? "" : "s"}?`, confirmLabel: "Archive", cancelLabel: "Cancel", danger: false, }); if (!confirmed) return; setIsApplying(true); const archivedIds: string[] = []; const failedIds: string[] = []; try { for (const task of actionableTasks) { try { await onArchiveTask(task.id); archivedIds.push(task.id); } catch { failedIds.push(task.id); } } } finally { setIsApplying(false); } if (archivedIds.length > 0) { setSelectedTaskIds((previous) => { const next = new Set(previous); for (const id of archivedIds) { next.delete(id); } return next; }); } addToast( `Archived ${archivedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`, failedIds.length > 0 ? "error" : "success", ); }, [addToast, confirm, onArchiveTask, selectedTaskIds, tasks]); 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; nodeId?: 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); } } } if (nodeOverride !== "__no_change__") { if (nodeOverride === "") { payload.nodeId = null; } else { payload.nodeId = nodeOverride; } } // 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, payload.nodeId, projectId, ); if (onTasksUpdated) { onTasksUpdated(result.updated); } addToast(`Updated ${taskIds.length} task${taskIds.length === 1 ? "" : "s"}`, "success"); // Reset state clearSelection(); setExecutorModel("__no_change__"); setValidatorModel("__no_change__"); setNodeOverride("__no_change__"); } catch (err) { addToast(getErrorMessage(err) || "Failed to update models", "error"); } finally { setIsApplying(false); } }, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]); const handleRowClick = useCallback( (task: Task) => { if (isMobile) { onOpenDetail(task, { origin: "list-mobile" }); return; } setSelectedTaskId(task.id); setSelectedTaskSnapshot(task); }, [isMobile, onOpenDetail] ); // Debounce detail fetches so rapid keyboard/mouse navigation through a // long task list doesn't issue a heavy /tasks/:id request (with log + // comments) per row. Only the task the user lands on triggers a fetch. const detailFetchTimerRef = useRef | null>(null); const detailFetchTargetRef = useRef(null); useEffect(() => { return () => { if (detailFetchTimerRef.current) { clearTimeout(detailFetchTimerRef.current); } }; }, []); const handleEmbeddedOpenDetail = useCallback((nextTask: Task | TaskDetail) => { setSelectedTaskId(nextTask.id); setSelectedTaskSnapshot(nextTask); if ("prompt" in nextTask) { detailFetchTargetRef.current = null; if (detailFetchTimerRef.current) { clearTimeout(detailFetchTimerRef.current); detailFetchTimerRef.current = null; } return; } detailFetchTargetRef.current = nextTask.id; if (detailFetchTimerRef.current) { clearTimeout(detailFetchTimerRef.current); } detailFetchTimerRef.current = setTimeout(() => { detailFetchTimerRef.current = null; const targetId = detailFetchTargetRef.current; if (targetId !== nextTask.id) { return; } fetchTaskDetail(nextTask.id, projectId) .then((detail) => { if (detailFetchTargetRef.current !== detail.id) { return; } setSelectedTaskSnapshot((previous) => { if (!previous || previous.id !== detail.id) { return previous; } return { ...previous, ...detail }; }); }) .catch(() => { // Keep optimistic inline selection when detail fetch fails. }); }, 200); }, [projectId]); 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 handleSplitResizeStart = useCallback((event: React.MouseEvent) => { if (isMobile) return; event.preventDefault(); const container = splitLayoutRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const onMouseMove = (moveEvent: MouseEvent) => { const proposedWidth = moveEvent.clientX - rect.left; setSidebarWidth(clampSidebarWidth(proposedWidth, rect.width)); }; const onMouseUp = () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); }, [isMobile]); const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMobile) return; const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0; const fallbackWidth = sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP; const containerWidth = Math.max(measuredWidth, fallbackWidth); const maxWidth = getSidebarMaxWidth(containerWidth); if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); const delta = event.key === "ArrowLeft" ? -LIST_SIDEBAR_KEYBOARD_STEP : LIST_SIDEBAR_KEYBOARD_STEP; setSidebarWidth((current) => clampSidebarWidth(current + delta, containerWidth)); return; } if (event.key === "Home") { event.preventDefault(); setSidebarWidth(LIST_SIDEBAR_MIN_WIDTH); return; } if (event.key === "End") { event.preventDefault(); setSidebarWidth(maxWidth); } }, [isMobile, sidebarWidth]); 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 { const task = tasks.find((candidate) => candidate.id === taskId); const hasStepProgress = task?.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"); } }, [onMoveTask, addToast, tasks, confirm] ); const getSortIcon = (field: SortField) => { if (!sortField || sortField !== field) return ; return sortDirection === "asc" ? ( ) : ( ); }; const renderViewOptionsPanel = (panelId: string) => (
{ALL_LIST_COLUMNS.map((column) => { const isVisible = visibleColumns.has(column); const isLastVisible = isVisible && visibleColumns.size === 1; return ( ); })}
{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}
); })}
); const renderBulkEditToolbars = () => ( <>
{availableModels && availableModels.length > 0 ? (
Bulk Edit Models & Node:
{selectedOverrideNode ? : null}
) : null} ); return (
{isMobile && ( <>
{onNewTask ? ( ) : null}
{selectedColumn ? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}` : `${filteredCount} of ${tasks.length} tasks`}
{viewOptionsOpen ? (
{renderViewOptionsPanel("list-view-options-panel-mobile")}
) : null} {bulkEditEnabled ? ( selectedTaskIds.size > 0 ? (
{renderBulkEditToolbars()}
) : (
{`${selectedTaskIds.size} selected`}
) ) : null} )}
{!isMobile && ( )}
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} onOpenTask={(taskId) => { const matchingTask = tasks.find((candidate) => candidate.id === taskId); if (matchingTask) { onOpenDetail(matchingTask); return; } if (typeof window !== "undefined") { window.location.hash = `#/tasks/${taskId}`; } }} />
{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 = bulkEditEnabled; return (
handleRowClick(task)} data-id={task.id} > {isSelectionMode && ( )}
{task.id} {task.executionMode === "fast" && ( )} {isPaused && task.pausedByAgentId ? ( paused by agent ) : isStuckState ? ( Stuck ) : hasStatus ? ( {getTaskStatusLabel(task.status ?? "")} ) : null}
{task.title || task.description}
{(hasDependencies || hasProgress) && (
{hasDependencies && ( {task.dependencies.length} )} {hasProgress && (
{taskProgress.label}
)}
)}
); }) )} )} ); })}
) : ( {bulkEditEnabled && ( )} {visibleColumns.has("title") && ( )} {visibleColumns.has("status") && ( )} {visibleColumns.has("column") && ( )} {visibleColumns.has("retries") && ( )} {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} > {bulkEditEnabled && ( )} {visibleColumns.has("title") && ( )} {visibleColumns.has("status") && ( )} {visibleColumns.has("column") && ( )} {visibleColumns.has("retries") && ( )} {visibleColumns.has("dependencies") && ( )} {visibleColumns.has("progress") && ( )} ); }) )} )} ); })}
{ if (el) el.indeterminate = isSelectIndeterminate; }} onChange={toggleSelectAll} aria-label="Select all visible tasks" /> handleSort("title")}> Title {getSortIcon("title")} handleSort("status")}> Status {getSortIcon("status")} handleSort("column")}> Column {getSortIcon("column")} handleSort("retries")}> Retries {getSortIcon("retries")} 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" && ( )} {task.title || task.description}
{isPaused && task.pausedByAgentId ? ( paused by agent ) : isStuckState ? ( Stuck ) : task.status ? ( {getTaskStatusLabel(task.status ?? "")} ) : ( - )} {COLUMN_LABELS[task.column]} {(task.retrySummary?.total ?? 0) > 0 ? (task.retrySummary?.total ?? 0) : "—"} {task.dependencies && task.dependencies.length > 0 ? ( {task.dependencies.length} ) : ( "-" )} {(() => { const taskProgress = getTaskProgress(task); if (!taskProgress.hasProgress) return "-"; return (
{taskProgress.label}
); })()}
)}
{!isMobile && ( <>
{!selectedTaskSnapshot ? (

Select a task to view details

) : (
{ setSelectedTaskSnapshot((previous) => { if (!previous || previous.id !== updatedTask.id) return previous; return { ...previous, ...updatedTask }; }); }} addToast={addToast} prAuthAvailable={prAuthAvailable} />
)}
)}
); }