import "./ListView.css"; import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive, Plus } from "lucide-react"; import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, fetchTaskDetail } from "../api"; import { TaskDetailContent } from "./TaskDetailModal"; import type { BoardWorkflowColumn, BoardWorkflowDefinition, BoardWorkflowsPayload, 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, extractLineageDeleteConflict } from "../utils/taskDelete"; import { subscribeSse } from "../sse-bus"; 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)", }; /** #1403: resolve a column color by id; workflow-defined custom columns that * have no legacy color fall back to the neutral accent rather than `undefined`. */ function columnColor(column: ColumnId): string { return (COLUMN_COLOR_MAP as Record)[column] ?? "var(--accent)"; } const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); type SortField = "title" | "status" | "column" | "retries"; function getTaskStatusLabel(status: string, t: TFunction<"app">): string { if (status === "merging-fix") return t("listView.statusMergingFix", "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"], t: TFunction<"app">): string { if (status === "online") return t("listView.nodeStatusOnline", "Online"); if (status === "connecting") return t("listView.nodeStatusConnecting", "Connecting"); if (status === "error") return t("listView.nodeStatusError", "Error"); return t("listView.nodeStatusOffline", "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 unknown[]; const validColumns = parsed.filter((col): col is ColumnId => typeof col === "string"); 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: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onRetryTask?: (id: string) => Promise; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; }) => Promise; onPauseTask?: (id: string) => Promise; onUnpauseTask?: (id: string) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => 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; onCreateWorkflow?: () => void; } const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ id: column, name: column, flags: { intake: column === "triage", countsTowardWip: column === "in-progress", mergeBlocker: column === "in-review", complete: column === "done", archived: column === "archived", hold: column === "todo", }, })); 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, onCreateWorkflow, }: ListViewProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); 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 [boardWorkflows, setBoardWorkflows] = useState(null); const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const { confirm, confirmWithChoice } = 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(() => { const persistedSelection = readSelectedTaskId(projectId); return persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null; }); const [sidebarWidth, setSidebarWidth] = useState(() => readSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const splitSidebarRef = useRef(null); const previousStorageProjectIdRef = useRef(projectId); const boardWorkflowsFetchSeqRef = useRef(0); useEffect(() => { if (previousStorageProjectIdRef.current === projectId) return; previousStorageProjectIdRef.current = projectId; 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]); useEffect(() => { const runFetch = () => { const seq = ++boardWorkflowsFetchSeqRef.current; fetchBoardWorkflows(projectId) .then((payload) => { if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); }) .catch(() => { if (seq === boardWorkflowsFetchSeqRef.current) { setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); } }); }; runFetch(); const onVisible = () => { if (typeof document === "undefined" || document.visibilityState === "visible") runFetch(); }; if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); if (typeof window !== "undefined") window.addEventListener("focus", onVisible); const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const unsubscribe = subscribeSse(`/api/events${query}`, { events: { "workflow:created": runFetch, "workflow:updated": runFetch, "workflow:deleted": runFetch, }, }); return () => { boardWorkflowsFetchSeqRef.current++; if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible); if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); unsubscribe(); }; }, [projectId]); // 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; } if (previous === liveTask) return previous; 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; }); }, []); const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0; const workflowOptions = useMemo(() => { if (!workflowMode || !boardWorkflows) return []; return [...boardWorkflows.workflows].sort((a, b) => { if (a.id === boardWorkflows.defaultWorkflowId) return -1; if (b.id === boardWorkflows.defaultWorkflowId) return 1; return a.name.localeCompare(b.name); }); }, [boardWorkflows, workflowMode]); const selectedWorkflow = useMemo(() => { if (!workflowMode) return null; return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId) ?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId) ?? workflowOptions[0] ?? null; }, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]); useEffect(() => { if (!workflowMode) { setSelectedWorkflowId(null); return; } if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) { setSelectedWorkflowId(selectedWorkflow.id); } }, [selectedWorkflow, selectedWorkflowId, workflowMode]); useEffect(() => { setSelectedColumn(null); }, [selectedWorkflowId]); const listColumns = useMemo(() => { if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS; return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard); }, [selectedWorkflow, workflowMode]); const columnNameById = useMemo(() => { const map = new Map(); for (const column of listColumns) { map.set(column.id, workflowMode ? column.name : columnLabel(column.id)); } return map; }, [columnLabel, listColumns, workflowMode]); const columnFlagsById = useMemo(() => { const map = new Map(); for (const column of listColumns) { map.set(column.id, column.flags); } return map; }, [listColumns]); const getListColumnLabel = useCallback((column: ColumnId): string => { return columnNameById.get(column) ?? columnLabel(column); }, [columnLabel, columnNameById]); const isArchivedColumn = useCallback((column: ColumnId): boolean => { return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived"; }, [columnFlagsById, workflowMode]); const isCompleteColumn = useCallback((column: ColumnId): boolean => { return workflowMode ? Boolean(columnFlagsById.get(column)?.complete) : column === "done"; }, [columnFlagsById, workflowMode]); const selectedWorkflowTaskIds = useMemo(() => { if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null; const ids = new Set(); for (const task of tasks) { const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId; if (workflowId === selectedWorkflow.id) ids.add(task.id); } return ids; }, [boardWorkflows, selectedWorkflow, tasks, workflowMode]); const createTargetColumn = useMemo(() => { const target = listColumns.find((column) => column.flags.intake && !column.flags.archived) ?? listColumns.find((column) => !column.flags.archived); return target?.id; }, [listColumns]); const handleListQuickCreate = useCallback((input: TaskCreateInput) => { const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error")); if (workflowMode && selectedWorkflow && createTargetColumn) { const explicitWorkflowId = selectedWorkflow.id.startsWith("builtin:") ? undefined : selectedWorkflow.id; return create({ ...input, column: input.column ?? createTargetColumn, workflowId: input.workflowId ?? explicitWorkflowId, }); } return create(input); }, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]); // Column display labels const COLUMN_LABELS_MAP: Record = { title: t("listView.colTitle", "Title"), status: t("listView.colStatus", "Status"), column: t("listView.colColumn", "Column"), dependencies: t("listView.colDependencies", "Dependencies"), progress: t("listView.colProgress", "Progress"), retries: t("listView.colRetries", "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: ColumnId) => { setSelectedColumn((prev) => (prev === column ? null : column)); }, []); const toggleSection = useCallback((column: ColumnId) => { 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]; if (selectedWorkflowTaskIds) { filtered = filtered.filter((task) => selectedWorkflowTaskIds.has(task.id)); } const hiddenCompletedColumns = new Set( listColumns .filter((column) => column.flags.complete || column.flags.archived) .map((column) => column.id), ); // 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) => !hiddenCompletedColumns.has(t.column)); } // 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 = {}; for (const column of listColumns) groups[column.id] = []; columnFiltered.forEach((task) => { const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN); if (groups[column]) groups[column].push(task); }); for (const column of listColumns) { const columnId = column.id; if (!sortField) { groups[columnId] = sortTasksForDisplayColumn(groups[columnId], columnId as Column); continue; } groups[columnId] = [...groups[columnId]].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, selectedWorkflowTaskIds, listColumns, workflowMode, hideDoneTasks, selectedColumn, staleOnlyFilter, stalePausedReviewOnlyFilter, sortField, sortDirection]); // 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(() => { const completedColumns = new Set( listColumns .filter((column) => column.flags.complete || column.flags.archived) .map((column) => column.id), ); return tasks.filter((task) => { if (selectedWorkflowTaskIds && !selectedWorkflowTaskIds.has(task.id)) return false; return completedColumns.has(task.column); }).length; }, [listColumns, selectedWorkflowTaskIds, 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) => !isArchivedColumn(t.column)) // 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, isArchivedColumn]); // Check if all visible tasks are selected const isSelectAll = useMemo(() => { const visibleTaskIds = Object.values(groupedTasks) .flat() .filter((t) => !isArchivedColumn(t.column)); if (visibleTaskIds.length === 0) return false; return visibleTaskIds.every((t) => selectedTaskIds.has(t.id)); }, [groupedTasks, isArchivedColumn, selectedTaskIds]); // Check if some (but not all) visible tasks are selected const isSelectIndeterminate = useMemo(() => { const visibleTaskIds = Object.values(groupedTasks) .flat() .filter((t) => !isArchivedColumn(t.column)); if (visibleTaskIds.length === 0) return false; const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length; return selectedCount > 0 && selectedCount < visibleTaskIds.length; }, [groupedTasks, isArchivedColumn, 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(t("listView.bulkDeleteNoTasks", "No selected tasks can be deleted (archived tasks are excluded)"), "error"); return; } const doneTasks = deletableTasks.filter((task) => task.column === "done"); const otherTasks = deletableTasks.filter((task) => task.column !== "done"); let shouldDeleteAll = false; let shouldArchiveDoneInstead = false; if (doneTasks.length > 0 && onArchiveTask) { const choice = await confirmWithChoice({ title: t("listView.bulkDeleteTitle", "Delete Selected Tasks"), message: t("listView.bulkDeleteWithDoneMessage", "Delete {{deletable}} task(s), or archive the {{done}} done task(s) and delete the rest?", { deletable: deletableTasks.length, done: doneTasks.length }), confirmLabel: t("listView.bulkDeleteAll", "Delete All"), cancelLabel: t("common.cancel", "Cancel"), tertiaryLabel: t("listView.bulkArchiveDone", "Archive {{count}} Done", { count: doneTasks.length }), danger: true, }); if (choice === "cancel") return; shouldDeleteAll = choice === "primary"; shouldArchiveDoneInstead = choice === "tertiary"; } else { const confirmed = await confirm({ title: t("listView.bulkDeleteTitle", "Delete Selected Tasks"), message: t("listView.bulkDeleteMessage", "Delete {{count}} selected task(s)?", { count: deletableTasks.length }), confirmLabel: t("common.delete", "Delete"), cancelLabel: t("common.cancel", "Cancel"), danger: true, }); if (!confirmed) return; shouldDeleteAll = true; } setIsApplying(true); const deletedIds: string[] = []; const archivedIds: string[] = []; const failedIds: string[] = []; const skippedIds = archivedTasks.map((task) => task.id); try { const tasksToDelete = shouldDeleteAll ? deletableTasks : otherTasks; if (shouldArchiveDoneInstead && onArchiveTask) { for (const task of doneTasks) { try { await onArchiveTask(task.id); archivedIds.push(task.id); } catch (err) { const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { failedIds.push(task.id); continue; } const confirmedArchive = await confirm({ title: t("listView.forceDeleteTitle", "Force Delete Task"), message: t("listView.lineageArchiveMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }), confirmLabel: t("common.archive", "Archive"), cancelLabel: t("common.skip", "Skip"), danger: true, }); if (!confirmedArchive) { failedIds.push(task.id); continue; } try { await onArchiveTask(task.id, { removeLineageReferences: true }); archivedIds.push(task.id); } catch { failedIds.push(task.id); } } } } for (const task of tasksToDelete) { try { await onDeleteTask(task.id); deletedIds.push(task.id); } catch (err) { const dependencyConflict = extractDependencyDeleteConflict(err); if (dependencyConflict) { const forceDelete = await confirm({ title: t("listView.forceDeleteTitle", "Force Delete Task"), message: t("listView.dependentsDeleteMessage", "Task {{taskId}} has dependents: {{dependents}}. Remove dependency references and force delete?", { taskId: task.id, dependents: dependencyConflict.dependentIds.join(", ") }), confirmLabel: t("listView.forceDelete", "Force Delete"), cancelLabel: t("common.skip", "Skip"), danger: true, }); if (!forceDelete) { failedIds.push(task.id); continue; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, }); deletedIds.push(task.id); } catch (retryErr) { const lineageConflict = extractLineageDeleteConflict(retryErr); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { failedIds.push(task.id); continue; } const forceLineageDelete = await confirm({ title: t("listView.forceDeleteTitle", "Force Delete Task"), message: t("listView.lineageDeleteMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }), confirmLabel: t("listView.forceDelete", "Force Delete"), cancelLabel: t("common.skip", "Skip"), danger: true, }); if (!forceLineageDelete) { failedIds.push(task.id); continue; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, }); deletedIds.push(task.id); } catch { failedIds.push(task.id); } } continue; } const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { failedIds.push(task.id); continue; } const forceDelete = await confirm({ title: t("listView.forceDeleteTitle", "Force Delete Task"), message: t("listView.lineageDeleteMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }), confirmLabel: t("listView.forceDelete", "Force Delete"), cancelLabel: t("common.skip", "Skip"), danger: true, }); if (!forceDelete) { failedIds.push(task.id); continue; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, }); deletedIds.push(task.id); } catch { failedIds.push(task.id); } } } } finally { setIsApplying(false); } if (deletedIds.length > 0 || archivedIds.length > 0) { setSelectedTaskIds((previous) => { const next = new Set(previous); for (const id of deletedIds) { next.delete(id); } for (const id of archivedIds) { next.delete(id); } return next; }); } const summaryMessage = shouldArchiveDoneInstead ? t("listView.bulkDeleteArchiveSummary", "Archived {{archived}}, deleted {{deleted}}, failed {{failed}}", { archived: archivedIds.length, deleted: deletedIds.length, failed: failedIds.length }) : t("listView.bulkDeleteSummary", { count: deletedIds.length, skipped: skippedIds.length, failed: failedIds.length, defaultValue_one: "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", defaultValue_other: "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed" }); addToast(summaryMessage, failedIds.length > 0 ? "error" : "success"); }, [addToast, confirm, confirmWithChoice, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]); const handleBulkPause = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onPauseTask) { addToast(t("listView.pauseUnavailable", "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(t("listView.bulkPauseNoTasks", "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( t("listView.bulkPauseSummary", "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", { paused: pausedIds.length, skipped: skippedCount, failed: failedIds.length }), failedIds.length > 0 ? "error" : "success", ); }, [addToast, onPauseTask, selectedTaskIds, tasks]); const handleBulkUnpause = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onUnpauseTask) { addToast(t("listView.unpauseUnavailable", "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(t("listView.bulkUnpauseNoTasks", "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( t("listView.bulkUnpauseSummary", "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", { unpaused: unpausedIds.length, skipped: skippedCount, failed: failedIds.length }), failedIds.length > 0 ? "error" : "success", ); }, [addToast, onUnpauseTask, selectedTaskIds, tasks]); const handleBulkArchive = useCallback(async () => { if (selectedTaskIds.size === 0) return; if (!onArchiveTask) { addToast(t("listView.archiveUnavailable", "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) => isCompleteColumn(task.column)); const skippedCount = selectedTasks.length - actionableTasks.length; if (actionableTasks.length === 0) { addToast(t("listView.bulkArchiveNoTasks", "No selected tasks can be archived (only done tasks)"), "error"); return; } const confirmed = await confirm({ title: t("listView.bulkArchiveTitle", "Archive Selected Tasks"), message: t("listView.bulkArchiveMessage", "Archive {{count}} selected task(s)?", { count: actionableTasks.length }), confirmLabel: t("common.archive", "Archive"), cancelLabel: t("common.cancel", "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 (err) { const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { failedIds.push(task.id); continue; } const confirmedArchive = await confirm({ title: t("listView.forceDeleteTitle", "Force Delete Task"), message: t("listView.lineageArchiveMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }), confirmLabel: t("common.archive", "Archive"), cancelLabel: t("common.skip", "Skip"), danger: true, }); if (!confirmedArchive) { failedIds.push(task.id); continue; } try { await onArchiveTask(task.id, { removeLineageReferences: true }); 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( t("listView.bulkArchiveSummary", "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", { archived: archivedIds.length, skipped: skippedCount, failed: failedIds.length }), failedIds.length > 0 ? "error" : "success", ); }, [addToast, confirm, isCompleteColumn, 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 && !isArchivedColumn(task.column); }); if (taskIds.length === 0) { addToast(t("listView.bulkUpdateNoTasks", "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(t("listView.bulkNoChanges", "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(t("listView.bulkUpdateSuccess", "Updated {{count}} task(s)", { count: taskIds.length }), "success"); // Reset state clearSelection(); setExecutorModel("__no_change__"); setValidatorModel("__no_change__"); setNodeOverride("__no_change__"); } catch (err) { addToast(getErrorMessage(err) || t("listView.bulkUpdateFailed", "Failed to update models"), "error"); } finally { setIsApplying(false); } }, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, 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: ColumnId) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverColumn(column); }, [] ); const handleColumnDragLeave = useCallback(() => { setDragOverColumn(null); }, []); const handleColumnDrop = useCallback( async (e: React.DragEvent, column: ColumnId) => { e.preventDefault(); setDragOverColumn(null); const taskId = e.dataTransfer.getData("text/plain"); if (!taskId) return; // Prevent dropping into archived column if (column === "archived" || columnFlagsById.get(column)?.archived) { addToast(t("listView.archiveViaButton", "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: t("listView.preserveProgressTitle", "Preserve Progress?"), message: t("listView.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"), confirmLabel: t("listView.keepProgress", "Keep Progress"), cancelLabel: t("listView.resetProgress", "Reset Progress"), }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: t("listView.resetProgressTitle", "Reset Progress?"), message: t("listView.resetProgressMessage", "Reset all step progress before moving this task?"), confirmLabel: t("listView.resetProgress", "Reset Progress"), cancelLabel: t("listView.cancelMove", "Cancel Move"), danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(taskId, column, moveOptions); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [addToast, columnFlagsById, confirm, onMoveTask, tasks, t] ); const getSortIcon = (field: SortField) => { if (!sortField || sortField !== field) return ; return sortDirection === "asc" ? ( ) : ( ); }; const renderWorkflowSelector = () => { if (!workflowMode || workflowOptions.length <= 1 || !selectedWorkflow) return null; return (
{onCreateWorkflow && ( )}
); }; const renderViewOptionsPanel = (panelId: string) => (
{ALL_LIST_COLUMNS.map((column) => { const isVisible = visibleColumns.has(column); const isLastVisible = isVisible && visibleColumns.size === 1; return ( ); })}
{listColumns.map((columnDef) => { const column = columnDef.id; const totalCount = selectedWorkflowTaskIds ? tasks.filter((task) => task.column === column && selectedWorkflowTaskIds.has(task.id)).length : tasks.filter((task) => task.column === column).length; const isCompletedColumn = Boolean(columnDef.flags.complete || columnDef.flags.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} > {getListColumnLabel(column)} {showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
); })}
); const renderBulkEditToolbars = () => ( <>
{availableModels && availableModels.length > 0 ? (
{t("listView.bulkEditModelsLabel", "Bulk Edit Models & Node:")}
{selectedOverrideNode ? : null}
) : null} ); return (
{isMobile && ( <>
{renderWorkflowSelector()} {onNewTask ? ( ) : null}
{selectedColumn ? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) }) : t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
{viewOptionsOpen ? (
{renderViewOptionsPanel("list-view-options-panel-mobile")}
) : null} {bulkEditEnabled ? ( selectedTaskIds.size > 0 ? (
{renderBulkEditToolbars()}
) : (
{t("listView.selectedCount", "{{count}} selected", { count: selectedTaskIds.size })}
) ) : null} )}
{!isMobile && ( )}
{ 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 ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
) : isMobile ? (
{listColumns.map((columnDef) => { const column = columnDef.id; if (selectedColumn && column !== selectedColumn) return null; if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.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); } }} > {getListColumnLabel(column)} {columnTasks.length}
{!isCollapsed && ( <> {isEmpty ? (
{t("listView.noTasks", "No tasks")}
) : ( columnTasks.map((task) => { const isDoneColumn = task.column === "done"; const visualStatus = isDoneColumn ? "done" : task.status; const isFailed = !isDoneColumn && task.status === "failed"; const isPaused = !isDoneColumn && task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = !globalPaused && !isFailed && !isPaused && !isStuckState && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); const hasStatus = typeof visualStatus === "string" && visualStatus.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 ? ( {t("listView.pausedByAgent", "paused by agent")} ) : isStuckState ? ( {t("listView.stuck", "Stuck")} ) : hasStatus ? ( {getTaskStatusLabel(visualStatus ?? "", t)} ) : 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") && ( )} {listColumns.map((columnDef) => { const column = columnDef.id; // 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 && (columnDef.flags.complete || columnDef.flags.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 isDoneColumn = task.column === "done"; const visualStatus = isDoneColumn ? "done" : task.status; const isFailed = !isDoneColumn && task.status === "failed"; const isPaused = !isDoneColumn && task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = !globalPaused && !isFailed && !isPaused && !isStuckState && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus 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={t("listView.selectAll", "Select all visible tasks")} /> handleSort("title")}> {t("listView.colTitle", "Title")} {getSortIcon("title")} handleSort("status")}> {t("listView.colStatus", "Status")} {getSortIcon("status")} handleSort("column")}> {t("listView.colColumn", "Column")} {getSortIcon("column")} handleSort("retries")}> {t("listView.colRetries", "Retries")} {getSortIcon("retries")} {t("listView.colDependencies", "Dependencies")}{t("listView.colProgress", "Progress")}
{getListColumnLabel(column)} {columnTasks.length}
{t("listView.noTasks", "No tasks")}
{ e.stopPropagation(); toggleTaskSelection(task.id); }} onClick={(e) => e.stopPropagation()} disabled={task.column === "archived"} aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })} />
{task.id}
{task.executionMode === "fast" && ( )} {task.title || task.description}
{isPaused && task.pausedByAgentId ? ( {t("listView.pausedByAgent", "paused by agent")} ) : isStuckState ? ( {t("listView.stuck", "Stuck")} ) : visualStatus ? ( {getTaskStatusLabel(visualStatus ?? "", t)} ) : ( - )} {getListColumnLabel(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 ? (

{t("listView.selectTaskPrompt", "Select a task to view details")}

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