"use client"; import { memo, useMemo } from "react"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { TaskItem } from "./task-item"; import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu"; import { countGroupTasks, type GroupedSidebarList, type SidebarGroup, } from "@/lib/sidebar/apply-view"; import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; import { SortableTaskLevel, SortableTaskNode } from "./task-switcher-subtask-dnd"; import { GroupHeader, TaskSwitcherSkeleton } from "./task-switcher-group"; export type TaskSwitcherItem = { id: string; title: string; state?: TaskState; sessionState?: TaskSessionState; description?: string; workflowId?: string; workflowName?: string; workflowStepId?: string; workflowStepTitle?: string; repositoryPath?: string; repositories?: string[]; diffStats?: { additions: number; deletions: number }; isRemoteExecutor?: boolean; remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; createdAt?: string; isArchived?: boolean; primarySessionId?: string | null; hasPendingClarification?: boolean; hasPendingPermission?: boolean; parentTaskTitle?: string; parentTaskId?: string; workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; prInfo?: { number: number; state: string }; isPRReview?: boolean; isIssueWatch?: boolean; issueInfo?: { url: string; number: number }; agentErrorMessage?: string | null; }; type TaskSwitcherProps = { grouped: GroupedSidebarList; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; collapsedGroupKeys?: string[]; onToggleGroup?: (groupKey: string) => void; collapsedSubtaskParentIds?: string[]; onToggleSubtasks?: (parentTaskId: string) => void; onSelectTask: (taskId: string) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void; pinnedTaskIds?: string[]; deletingTaskId?: string | null; isLoading?: boolean; totalTaskCount?: number; // Multi-select (cmd/shift click). When the selection is non-empty, plain // clicks toggle instead of navigating; the context menu acts on the selection. selectedTaskIds?: Set; onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; type TaskLinkHandler = (taskId: string, taskTitle?: string) => void; /** * Modifier-aware sidebar row click: cmd/ctrl toggles one task, shift extends a * range, a plain click toggles while a selection is active and otherwise * navigates to the task. */ /** @internal Exported for unit testing the modifier-aware click dispatch. */ export function dispatchSidebarRowClick( e: React.MouseEvent | React.KeyboardEvent, taskId: string, isSelecting: boolean, handlers: { onSelectTask: (taskId: string) => void; onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; }, ): void { // Only intercept a modifier click when the matching handler is wired (the // mobile switcher renders without selection handlers — there a Cmd/Shift click // must still navigate rather than become a no-op). if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelectTask) { e.preventDefault(); handlers.onToggleSelectTask(taskId); return; } if (e.shiftKey && handlers.onSelectTaskRange) { e.preventDefault(); handlers.onSelectTaskRange(taskId); return; } if (isSelecting && handlers.onToggleSelectTask) { handlers.onToggleSelectTask(taskId); return; } handlers.onSelectTask(taskId); } type SubtaskToggleInfo = { subtaskCount: number; subtasksCollapsed: boolean; onToggleSubtasks: () => void; }; type TaskRowProps = { task: TaskSwitcherItem; isSubTask?: boolean; depth?: number; subtaskToggle?: SubtaskToggleInfo; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; pinnedTaskIds?: string[]; deletingTaskId?: string | null; selectedTaskIds?: Set; onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; function taskLinkHandlerProps(props: Pick) { return { onLinkPullRequest: props.onLinkPullRequest, onLinkIssue: props.onLinkIssue, onLinkMergeRequest: props.onLinkMergeRequest, onLinkJiraTicket: props.onLinkJiraTicket, onLinkLinearIssue: props.onLinkLinearIssue, onLinkSentryIssue: props.onLinkSentryIssue, }; } type TaskLinkHandlerProps = { onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; }; function TaskRow({ task, isSubTask, depth, subtaskToggle, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onDetachTask, onMoveToStep, onTogglePin, isPinned, pinnedTaskIds, deletingTaskId, selectedTaskIds, onToggleSelectTask, onSelectTaskRange, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, ...props }: TaskRowProps) { const isSelected = task.id === selectedTaskId || task.id === activeTaskId; const isMultiSelected = selectedTaskIds?.has(task.id) ?? false; const isSelecting = (selectedTaskIds?.size ?? 0) > 0; const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined; return ( dispatchSidebarRowClick(e, task.id, isSelecting, { onSelectTask, onToggleSelectTask, onSelectTaskRange, }) } title={task.title} state={task.state} sessionState={task.sessionState} isArchived={task.isArchived} isSelected={isSelected} diffStats={task.diffStats} isRemoteExecutor={task.isRemoteExecutor} remoteExecutorType={task.remoteExecutorType} remoteExecutorName={task.remoteExecutorName} taskId={task.id} primarySessionId={task.primarySessionId ?? null} hasPendingClarification={task.hasPendingClarification} hasPendingPermission={task.hasPendingPermission} updatedAt={task.updatedAt} repositories={task.repositories} prInfo={task.prInfo} issueInfo={task.issueInfo} agentErrorMessage={task.agentErrorMessage} isSubTask={isSubTask} depth={depth} subtaskCount={subtaskToggle?.subtaskCount} subtasksCollapsed={subtaskToggle?.subtasksCollapsed} onToggleSubtasks={subtaskToggle?.onToggleSubtasks} onClick={() => onSelectTask(task.id)} isDeleting={deletingTaskId === task.id} isPinned={isPinned} /> ); } // Shared, per-render context threaded through the recursive task tree so each // node can look up its children, collapse state, and reorder callbacks without // drilling a dozen props through every level. type TaskTreeContext = { subTasksByParentId: Map; collapsedSubs: Set; onToggleSubtasks?: (parentTaskId: string) => void; pinnedSet: Set; rowProps: Omit; onReorderGroup?: (groupTaskIds: string[]) => void; onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void; }; // One task row plus — when expanded — its nested subtree. Mutually recursive // with TaskTreeLevel, so it renders arbitrarily deep hierarchies. function TaskTreeNode({ task, depth, ctx, isDraggable, }: { task: TaskSwitcherItem; depth: number; ctx: TaskTreeContext; isDraggable: boolean; }) { const subs = ctx.subTasksByParentId.get(task.id); const hasSubs = !!subs?.length; const subsHidden = hasSubs && !!ctx.onToggleSubtasks && ctx.collapsedSubs.has(task.id); const toggleInfo: SubtaskToggleInfo | undefined = hasSubs && ctx.onToggleSubtasks ? { subtaskCount: countGroupTasks(subs!, ctx.subTasksByParentId), subtasksCollapsed: subsHidden, onToggleSubtasks: () => ctx.onToggleSubtasks!(task.id), } : undefined; const isRoot = depth === 0; const handle = ( ); const nested = !subsHidden && hasSubs ? ( ) : undefined; return ( ); } // One level of sibling tasks. `parentTaskId === null` is the group root (whose // reorder maps to onReorderGroup); deeper levels reorder via onReorderSubtasks // scoped to that parent's children. function TaskTreeLevel({ parentTaskId, tasks, depth, ctx, }: { parentTaskId: string | null; tasks: TaskSwitcherItem[]; depth: number; ctx: TaskTreeContext; }) { let onReorder: ((orderedTaskIds: string[]) => void) | undefined; if (parentTaskId === null) { onReorder = ctx.onReorderGroup; } else if (ctx.onReorderSubtasks) { const pid = parentTaskId; onReorder = (ids: string[]) => ctx.onReorderSubtasks!(pid, ids); } return ( ( )} /> ); } type GroupSectionProps = { group: SidebarGroup; subTasksByParentId: Map; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; isCollapsed: boolean; onToggleCollapsed: () => void; collapsedSubtaskParentIds?: string[]; onToggleSubtasks?: (parentTaskId: string) => void; showHeader: boolean; onSelectTask: (taskId: string) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void; pinnedTaskIds?: string[]; pinnedSet: Set; deletingTaskId?: string | null; selectedTaskIds?: Set; onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; function GroupSection({ group, subTasksByParentId, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, isCollapsed, onToggleCollapsed, collapsedSubtaskParentIds, onToggleSubtasks, showHeader, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onDetachTask, onLinkPullRequest, onLinkIssue, onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, onMoveToStep, onTogglePin, onReorderGroup, onReorderSubtasks, pinnedTaskIds, pinnedSet, deletingTaskId, selectedTaskIds, onToggleSelectTask, onSelectTaskRange, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, }: GroupSectionProps) { const totalCount = countGroupTasks(group.tasks, subTasksByParentId); const ctx: TaskTreeContext = { subTasksByParentId, collapsedSubs: new Set(collapsedSubtaskParentIds ?? []), onToggleSubtasks, pinnedSet, rowProps: { workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onDetachTask, onLinkPullRequest, onLinkIssue, onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, onMoveToStep, onTogglePin, pinnedTaskIds, deletingTaskId, selectedTaskIds, onToggleSelectTask, onSelectTaskRange, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, }, onReorderGroup, onReorderSubtasks, }; return (
{showHeader && ( )} {!isCollapsed && ( )}
); } export const TaskSwitcher = memo(function TaskSwitcher({ grouped, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, collapsedGroupKeys = [], onToggleGroup, collapsedSubtaskParentIds, onToggleSubtasks, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onDetachTask, onLinkPullRequest, onLinkIssue, onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, onMoveToStep, onTogglePin, onReorderGroup, onReorderSubtasks, pinnedTaskIds, deletingTaskId, isLoading = false, totalTaskCount, selectedTaskIds, onToggleSelectTask, onSelectTaskRange, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, }: TaskSwitcherProps) { const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); if (isLoading) return ; const totalTasks = totalTaskCount ?? grouped.groups.reduce((sum, g) => sum + g.tasks.length, 0); if (totalTasks === 0) { return
No tasks yet.
; } const collapsedSet = new Set(collapsedGroupKeys); const showHeaders = grouped.groups.length > 1 || (grouped.groups.length === 1 && grouped.groups[0].key !== "__all__"); return (
{grouped.groups.map((group) => ( onToggleGroup?.(group.key)} collapsedSubtaskParentIds={collapsedSubtaskParentIds} onToggleSubtasks={onToggleSubtasks} showHeader={showHeaders} onSelectTask={onSelectTask} onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} onLinkMergeRequest={onLinkMergeRequest} onLinkJiraTicket={onLinkJiraTicket} onLinkLinearIssue={onLinkLinearIssue} onLinkSentryIssue={onLinkSentryIssue} onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} onReorderGroup={onReorderGroup} onReorderSubtasks={onReorderSubtasks} pinnedTaskIds={pinnedTaskIds} pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} selectedTaskIds={selectedTaskIds} onToggleSelectTask={onToggleSelectTask} onSelectTaskRange={onSelectTaskRange} onBulkArchive={onBulkArchive} onBulkDelete={onBulkDelete} onBulkPin={onBulkPin} onBulkMove={onBulkMove} onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} /> ))}
); });