Files
fusion/packages/dashboard/app/components/ListView.tsx
Fusion 4135db0d77 feat(FN-3231): preserve merge-active state on verification bounce and board
This merge lands FN-3231 across two steps: it preserves a merge-active fix when verification bounces occur (step 1) and ensures the fix is retained during board routing transitions (step 2). Changes span the dashboard Board routing logic and the engine executor, with corresponding test coverage adde

Fusion-Task-Id: FN-3231
2026-05-04 07:00:39 -07:00

1597 lines
62 KiB
TypeScript

import "./ListView.css";
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap } from "lucide-react";
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
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";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
todo: "var(--todo)",
"in-progress": "var(--in-progress)",
"in-review": "var(--in-review)",
done: "var(--done)",
archived: "var(--text-secondary)",
};
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
type SortField = "id" | "title" | "status" | "column";
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", "dependencies", "progress"] as const;
const DEFAULT_LIST_COLUMNS = ["title", "status", "column"] 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<ListColumn> {
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 readCollapsedSections(projectId?: string): Set<Column> {
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<Column>();
}
function readSelectedTaskIds(projectId?: string): Set<string> {
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<string>();
}
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<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onNewTask?: () => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
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,
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<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const [selectedColumn, setSelectedColumn] = useState<Column | null>(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<Set<ListColumn>>(() => readVisibleColumns(projectId));
// Hide done tasks state - initialize from localStorage
const [hideDoneTasks, setHideDoneTasks] = useState<boolean>(() => readHideDoneTasks(projectId));
// Collapsed sections state - initialize from localStorage
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() =>
readCollapsedSections(projectId),
);
// Persist column visibility changes to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-list-columns", JSON.stringify([...visibleColumns]), projectId);
}
}, [projectId, visibleColumns]);
// Persist hide done tasks state to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-hide-done", hideDoneTasks.toString(), projectId);
}
}, [hideDoneTasks, projectId]);
// Persist collapsed sections state to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-list-collapsed", JSON.stringify([...collapsedSections]), projectId);
}
}, [collapsedSections, projectId]);
const [viewOptionsOpen, setViewOptionsOpen] = useState(false);
// Selection state - initialize from localStorage
const [bulkEditEnabled, setBulkEditEnabled] = useState(false);
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const splitSidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setVisibleColumns(readVisibleColumns(projectId));
setHideDoneTasks(readHideDoneTasks(projectId));
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<typeof setTimeout> | 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<ListColumn, string> = {
title: "Title",
status: "Status",
column: "Column",
dependencies: "Dependencies",
progress: "Progress",
};
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
}, [sortField]);
const handleColumnFilter = useCallback((column: Column) => {
setSelectedColumn((prev) => (prev === column ? null : column));
}, []);
const toggleSection = useCallback((column: Column) => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(column)) {
next.delete(column);
} else {
next.add(column);
}
return next;
});
}, []);
const clearColumnFilter = useCallback(() => {
setSelectedColumn(null);
}, []);
const groupedTasks = useMemo(() => {
// First apply text filter
let filtered = searchQuery
? tasks.filter(
(t) =>
t.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: [...tasks];
// Then filter out done and archived tasks if hideDoneTasks is enabled
// BUT only when no specific column is selected (strict hide semantics)
if (hideDoneTasks && !selectedColumn) {
filtered = filtered.filter((t) => t.column !== "done" && t.column !== "archived");
}
// Then apply column filter if selected
const columnFiltered = selectedColumn
? filtered.filter((t) => t.column === selectedColumn)
: filtered;
const sorted = [...columnFiltered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
comparison = a.id.localeCompare(b.id);
break;
case "title":
comparison = (a.title || a.description).localeCompare(b.title || b.description);
break;
case "status":
comparison = (a.status || "").localeCompare(b.status || "");
break;
case "column":
comparison = a.column.localeCompare(b.column);
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
// Group by column while preserving sort order within each group
const groups: Record<Column, Task[]> = {
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: [],
archived: []
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
// Calculate done and archived task counts for stats display
const completedTaskCount = useMemo(() => {
return tasks.filter((t) => t.column === "done" || t.column === "archived").length;
}, [tasks]);
// Calculate hidden done+archived tasks count
const hiddenCompletedCount = useMemo(() => {
if (!hideDoneTasks) return 0;
return completedTaskCount;
}, [hideDoneTasks, completedTaskCount]);
// Selection logic that depends on groupedTasks (must be after groupedTasks definition)
// Toggle all visible tasks
const toggleSelectAll = useCallback(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived") // Can't bulk edit archived
.map((t) => t.id);
setSelectedTaskIds((prev) => {
const allSelected = visibleTaskIds.every((id) => prev.has(id));
if (allSelected) {
// Deselect all visible
const next = new Set(prev);
visibleTaskIds.forEach((id) => next.delete(id));
return next;
} else {
// Select all visible
return new Set([...prev, ...visibleTaskIds]);
}
});
}, [groupedTasks]);
// Check if all visible tasks are selected
const isSelectAll = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
return visibleTaskIds.every((t) => selectedTaskIds.has(t.id));
}, [groupedTasks, selectedTaskIds]);
// Check if some (but not all) visible tasks are selected
const isSelectIndeterminate = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length;
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
}, [groupedTasks, selectedTaskIds]);
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
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 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<ReturnType<typeof setTimeout> | null>(null);
const detailFetchTargetRef = useRef<string | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 !== field) return <ArrowUpDown size={14} className="sort-icon" />;
return sortDirection === "asc" ? (
<ArrowUp size={14} className="sort-icon active" />
) : (
<ArrowDown size={14} className="sort-icon active" />
);
};
const renderViewOptionsPanel = (panelId: string) => (
<div id={panelId} className="list-view-options-panel">
<div className="list-view-options-columns">
{ALL_LIST_COLUMNS.map((column) => {
const isVisible = visibleColumns.has(column);
const isLastVisible = isVisible && visibleColumns.size === 1;
return (
<label
key={column}
className={`list-column-dropdown-item${isLastVisible ? " disabled" : ""}`}
title={isLastVisible ? "At least one column must be visible" : ""}
>
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleColumn(column)}
disabled={isLastVisible}
/>
<span>{COLUMN_LABELS_MAP[column]}</span>
</label>
);
})}
</div>
<button
className="btn btn-sm list-hide-done-toggle"
onClick={() => setHideDoneTasks((prev) => !prev)}
aria-pressed={hideDoneTasks}
title={hideDoneTasks ? "Show done tasks" : "Hide done tasks"}
>
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
{hideDoneTasks ? "Show Done" : "Hide Done"}
</button>
<div className="list-drop-zones list-drop-zones--sidebar">
{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 (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
onClick={() => handleColumnFilter(column)}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className={`list-section-dot dot-${column}`} />
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
<span className="drop-zone-count">
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
</span>
</div>
);
})}
</div>
</div>
);
return (
<div className="list-view">
{isMobile && (
<>
<div className="list-toolbar">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
</button>
{onNewTask ? (
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
+ New Task
</button>
) : null}
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel-mobile"
>
<Columns3 size={14} />
View options
</button>
<div className="list-stats">
{selectedColumn
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
: `${filteredCount} of ${tasks.length} tasks`}
</div>
</div>
{viewOptionsOpen ? (
<div className="list-toolbar-mobile-options">{renderViewOptionsPanel("list-view-options-panel-mobile")}</div>
) : null}
</>
)}
<div className="list-table-container">
<div className={isMobile ? "" : "list-split-layout"} data-testid={isMobile ? undefined : "list-split-layout"} ref={splitLayoutRef}>
<div
className={isMobile ? "" : "list-split-sidebar"}
data-testid={isMobile ? undefined : "list-split-sidebar"}
ref={splitSidebarRef}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
{!isMobile && (
<aside className="list-sidebar-controls" aria-label="List controls">
<div className="list-sidebar-controls__header">
<p className="list-stats">
{selectedColumn
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
: `${filteredCount} of ${tasks.length} tasks`}
{hiddenCompletedCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({hiddenCompletedCount} hidden)</span>
)}
</p>
<div className="list-sidebar-controls__actions">
{onNewTask ? (
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
+ New Task
</button>
) : null}
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
</button>
</div>
<div className="list-sidebar-summary-chips">
{selectedColumn ? (
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label="Clear column filter">
{`Filter: ${COLUMN_LABELS[selectedColumn]}`}
</button>
) : null}
{hideDoneTasks ? <span className="list-sidebar-chip">Done hidden</span> : null}
{bulkEditEnabled ? (
<span className="list-sidebar-chip">Bulk edit</span>
) : null}
{bulkEditEnabled && selectedTaskIds.size > 0 ? (
<button className="btn btn-sm" onClick={clearSelection}>
{`${selectedTaskIds.size} selected`}
</button>
) : null}
</div>
</div>
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel"
>
<Columns3 size={14} />
View options
</button>
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
{bulkEditEnabled && selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={executorModel}
onChange={setExecutorModel}
label="Executor Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={validatorModel}
onChange={setValidatorModel}
label="Reviewer Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select
className="select bulk-node-select"
value={nodeOverride}
onChange={(e) => setNodeOverride(e.target.value)}
aria-label="Node Override"
disabled={isLoadingNodes}
>
<option value="__no_change__">No change</option>
<option value="">Use project default</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>
</div>
)}
</aside>
)}
<div className="list-quick-entry-above-table">
<QuickEntryBox
onCreate={onQuickCreate ?? (async () => 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}
/>
</div>
{filteredCount === 0 ? (
<div className="list-empty">
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
</div>
) : isMobile ? (
<div className="list-cards">
{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 (
<Fragment key={column}>
<div
className={`list-card-section-header${isCollapsed ? " list-section-header--collapsed" : ""}`}
onClick={() => toggleSection(column)}
aria-expanded={!isCollapsed}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSection(column);
}
}}
>
<ChevronRight
size={14}
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
/>
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
<span className="list-section-count">{columnTasks.length}</span>
</div>
{!isCollapsed && (
<>
{isEmpty ? (
<div className="list-empty-cell list-card-empty">No tasks</div>
) : (
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 (
<div
key={task.id}
className={`list-card${isAgentActive ? " agent-active" : ""}${isSelectionMode ? " list-card--selectable" : ""}`}
onClick={() => handleRowClick(task)}
data-id={task.id}
>
{isSelectionMode && (
<label className="list-card-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedTaskIds.has(task.id)}
onChange={(e) => {
e.stopPropagation();
toggleTaskSelection(task.id);
}}
onClick={(e) => e.stopPropagation()}
disabled={task.column === "archived"}
aria-label={`Select ${task.id}`}
/>
</label>
)}
<div className="list-card-row">
<span className="list-card-id">{task.id}</span>
{task.executionMode === "fast" && (
<span
className="list-execution-mode-badge list-execution-mode-badge--fast"
title="Fast mode"
aria-label="Fast mode"
>
<Zap aria-hidden="true" />
<span className="visually-hidden">Fast mode</span>
</span>
)}
<span className="list-card-spacer" />
{isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">paused by agent</span>
) : isStuckState ? (
<span className="list-status-badge stuck">Stuck</span>
) : hasStatus ? (
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
{getTaskStatusLabel(task.status ?? "")}
</span>
) : null}
</div>
<div className="list-card-row">
<div className="list-card-title">{task.title || task.description}</div>
</div>
{(hasDependencies || hasProgress) && (
<div className="list-card-row list-card-meta">
{hasDependencies && (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
)}
{hasProgress && (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${taskProgress.percent}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{taskProgress.label}</span>
</div>
)}
</div>
)}
</div>
);
})
)}
</>
)}
</Fragment>
);
})}
</div>
) : (
<table className="list-table">
<thead>
<tr>
{bulkEditEnabled && (
<th className="list-header-cell list-header-checkbox">
<input
type="checkbox"
checked={isSelectAll}
ref={(el) => {
if (el) el.indeterminate = isSelectIndeterminate;
}}
onChange={toggleSelectAll}
aria-label="Select all visible tasks"
/>
</th>
)}
{visibleColumns.has("title") && (
<th className="list-header-cell" onClick={() => handleSort("title")}>
Title {getSortIcon("title")}
</th>
)}
{visibleColumns.has("status") && (
<th className="list-header-cell" onClick={() => handleSort("status")}>
Status {getSortIcon("status")}
</th>
)}
{visibleColumns.has("column") && (
<th className="list-header-cell" onClick={() => handleSort("column")}>
Column {getSortIcon("column")}
</th>
)}
{visibleColumns.has("dependencies") && (
<th className="list-header-cell">Dependencies</th>
)}
{visibleColumns.has("progress") && (
<th className="list-header-cell">Progress</th>
)}
</tr>
</thead>
<tbody>
{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 (
<Fragment key={column}>
{/* Section Header */}
<tr
className={`list-section-header${isCollapsed ? " list-section-header--collapsed" : ""}`}
onClick={() => toggleSection(column)}
aria-expanded={!isCollapsed}
>
<th colSpan={visibleColumns.size + (bulkEditEnabled ? 1 : 0)} className="list-section-cell">
<ChevronRight
size={14}
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
/>
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
<span className="list-section-count">{columnTasks.length}</span>
</th>
</tr>
{/* Task Rows - only render when not collapsed */}
{!isCollapsed && (
<>
{isEmpty ? (
<tr className="list-section-empty">
<td colSpan={visibleColumns.size + (bulkEditEnabled ? 1 : 0)} className="list-empty-cell">
No tasks
</td>
</tr>
) : (
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 (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isStuckState ? " stuck" : ""
}${isAgentActive ? " agent-active" : ""}${
isDragging ? " dragging" : ""
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
{bulkEditEnabled && (
<td className="list-cell list-cell-checkbox">
<input
type="checkbox"
checked={selectedTaskIds.has(task.id)}
onChange={(e) => {
e.stopPropagation();
toggleTaskSelection(task.id);
}}
onClick={(e) => e.stopPropagation()}
disabled={task.column === "archived"}
aria-label={`Select ${task.id}`}
/>
</td>
)}
{visibleColumns.has("title") && (
<td className="list-cell list-cell-title">
<div className="list-title-content">
<span className="list-title-id">{task.id}</span>
<div className="list-title-row">
{task.executionMode === "fast" && (
<span
className="list-execution-mode-badge list-execution-mode-badge--fast"
title="Fast mode"
aria-label="Fast mode"
>
<Zap aria-hidden="true" />
<span className="visually-hidden">Fast mode</span>
</span>
)}
<span className="list-title-text">{task.title || task.description}</span>
</div>
</div>
</td>
)}
{visibleColumns.has("status") && (
<td className="list-cell">
{isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">paused by agent</span>
) : isStuckState ? (
<span className="list-status-badge stuck">
Stuck
</span>
) : task.status ? (
<span
className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{getTaskStatusLabel(task.status ?? "")}
</span>
) : (
<span className="list-status-badge">-</span>
)}
</td>
)}
{visibleColumns.has("column") && (
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
)}
{visibleColumns.has("dependencies") && (
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
</td>
)}
{visibleColumns.has("progress") && (
<td className="list-cell list-cell-progress">
{(() => {
const taskProgress = getTaskProgress(task);
if (!taskProgress.hasProgress) return "-";
return (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${taskProgress.percent}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{taskProgress.label}</span>
</div>
);
})()}
</td>
)}
</tr>
);
})
)}
</>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{!isMobile && (
<>
<div
className="list-split-resize-handle"
data-testid="list-split-resize-handle"
onMouseDown={handleSplitResizeStart}
onKeyDown={handleSplitResizeKeyDown}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Resize task list sidebar"
aria-valuemin={LIST_SIDEBAR_MIN_WIDTH}
aria-valuemax={Math.round(
getSidebarMaxWidth(
splitLayoutRef.current?.clientWidth ??
(sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP)
)
)}
aria-valuenow={Math.round(sidebarWidth)}
/>
<div className="list-split-detail" data-testid="list-split-detail">
{!selectedTaskSnapshot ? (
<div className="list-split-detail-empty">
<p>Select a task to view details</p>
</div>
) : (
<div className="list-split-detail-content" data-testid="list-split-detail-content">
<TaskDetailContent
task={selectedTaskSnapshot}
projectId={projectId}
tasks={tasks}
embedded
onOpenDetail={handleEmbeddedOpenDetail}
onMoveTask={onMoveTask}
onDeleteTask={onDeleteTask}
onMergeTask={onMergeTask}
onRetryTask={onRetryTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onTaskUpdated={(updatedTask) => {
setSelectedTaskSnapshot((previous) => {
if (!previous || previous.id !== updatedTask.id) return previous;
return { ...previous, ...updatedTask };
});
}}
addToast={addToast}
prAuthAvailable={prAuthAvailable}
/>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}