"use client"; import { memo, useCallback, useMemo } from "react"; import { IconChevronDown } from "@tabler/icons-react"; import { DndContext, PointerSensor, closestCenter, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { cn } from "@/lib/utils"; import { TaskItem } from "./task-item"; import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu"; import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view"; import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; const DRAG_ACTIVATION_DISTANCE = 8; 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; prInfo?: { number: number; state: string }; isPRReview?: boolean; isIssueWatch?: boolean; issueInfo?: { url: string; number: number }; }; 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; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; pinnedTaskIds?: string[]; deletingTaskId?: string | null; isLoading?: boolean; totalTaskCount?: number; }; type SubtaskToggleInfo = { subtaskCount: number; subtasksCollapsed: boolean; onToggleSubtasks: () => void; }; function TaskSwitcherSkeleton() { return (
); } function GroupHeader({ label, groupKey, count, isCollapsed, onToggle, }: { label: string; groupKey: string; count: number; isCollapsed: boolean; onToggle: () => void; }) { return ( ); } type TaskRowProps = { task: TaskSwitcherItem; isSubTask?: boolean; 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; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; deletingTaskId?: string | null; }; function TaskRow({ task, isSubTask, subtaskToggle, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onMoveToStep, onTogglePin, isPinned, deletingTaskId, }: TaskRowProps) { const isSelected = task.id === selectedTaskId || task.id === activeTaskId; const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined; return ( onSelectTask(task.id)} isDeleting={deletingTaskId === task.id} isPinned={isPinned} /> ); } function SortableTaskBlock({ taskId, parent, subTasks, }: { taskId: string; parent: React.ReactNode; subTasks?: React.ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: taskId, }); const sortableAttributes = { ...attributes, role: undefined, "aria-roledescription": undefined, }; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : undefined, }; // setNodeRef stays on the outer wrapper so the CSS transform applies to // the parent row + its subtasks together. Drag listeners only attach to // the parent row's wrapper, so a pointer-down on a subtask row does NOT // trigger a drag of the parent block. return (
{parent}
{subTasks}
); } 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; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; pinnedSet: Set; deletingTaskId?: string | null; }; function useGroupDnd( groupTasks: TaskSwitcherItem[], onReorderGroup?: (groupTaskIds: string[]) => void, ) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), ); const handleDragEnd = useCallback( (event: DragEndEvent) => { if (!onReorderGroup) return; const { active, over } = event; if (!over || active.id === over.id) return; const ids = groupTasks.map((t) => t.id); const oldIndex = ids.indexOf(String(active.id)); const newIndex = ids.indexOf(String(over.id)); if (oldIndex < 0 || newIndex < 0) return; onReorderGroup(arrayMove(ids, oldIndex, newIndex)); }, [groupTasks, onReorderGroup], ); const sortableIds = useMemo(() => groupTasks.map((t) => t.id), [groupTasks]); return { sensors, handleDragEnd, sortableIds }; } function GroupTaskList({ group, subTasksByParentId, collapsedSubs, onToggleSubtasks, pinnedSet, rowProps, }: { group: SidebarGroup; subTasksByParentId: Map; collapsedSubs: Set; onToggleSubtasks?: (parentTaskId: string) => void; pinnedSet: Set; rowProps: Omit; }) { return ( <> {group.tasks.map((task) => { const subs = subTasksByParentId.get(task.id); const hasSubs = !!subs?.length; const subsHidden = hasSubs && !!onToggleSubtasks && collapsedSubs.has(task.id); const toggleInfo: SubtaskToggleInfo | undefined = hasSubs && onToggleSubtasks ? { subtaskCount: subs!.length, subtasksCollapsed: subsHidden, onToggleSubtasks: () => onToggleSubtasks(task.id), } : undefined; return ( } subTasks={ !subsHidden && subs?.map((sub) => ( // Subtasks aren't independently sortable or pinnable — pinning // would show an icon but `floatPinnedToTop` only operates on // root tasks, so the row wouldn't move. Drop both props to // avoid the misleading no-op menu item. )) } /> ); })} ); } function GroupSection({ group, subTasksByParentId, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, isCollapsed, onToggleCollapsed, collapsedSubtaskParentIds, onToggleSubtasks, showHeader, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onMoveToStep, onTogglePin, onReorderGroup, pinnedSet, deletingTaskId, }: GroupSectionProps) { const totalCount = group.tasks.reduce( (sum, t) => sum + 1 + (subTasksByParentId.get(t.id)?.length ?? 0), 0, ); const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []); const rowProps = { workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onMoveToStep, onTogglePin, deletingTaskId, }; const { sensors, handleDragEnd, sortableIds } = useGroupDnd(group.tasks, onReorderGroup); return (
{showHeader && ( )} {!isCollapsed && ( )}
); } export const TaskSwitcher = memo(function TaskSwitcher({ grouped, workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, collapsedGroupKeys = [], onToggleGroup, collapsedSubtaskParentIds, onToggleSubtasks, onSelectTask, onRenameTask, onArchiveTask, onDeleteTask, onMoveToStep, onTogglePin, onReorderGroup, pinnedTaskIds, deletingTaskId, isLoading = false, totalTaskCount, }: 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} onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} onReorderGroup={onReorderGroup} pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} /> ))}
); });