From 31237ad8713a98eea2c2b51cbcc0c0f656d40312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 15:51:44 +0100 Subject: [PATCH 01/19] feat: add anchored range selection to multi-select reducer --- .../hooks/use-task-multi-select-range.test.ts | 69 +++++++++++++++++++ apps/web/hooks/use-task-multi-select.test.ts | 1 + apps/web/hooks/use-task-multi-select.ts | 52 +++++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 apps/web/hooks/use-task-multi-select-range.test.ts diff --git a/apps/web/hooks/use-task-multi-select-range.test.ts b/apps/web/hooks/use-task-multi-select-range.test.ts new file mode 100644 index 0000000000..5f4b30ab9a --- /dev/null +++ b/apps/web/hooks/use-task-multi-select-range.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { multiSelectReducer, INITIAL_STATE } from "./use-task-multi-select"; + +describe("multiSelectReducer — anchor + range", () => { + const ordered = ["a", "b", "c", "d", "e"]; + + it("toggle_select sets the range anchor to the toggled task", () => { + const next = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "t1" }); + expect(next.anchorId).toBe("t1"); + }); + + it("select_range with no anchor selects only the clicked task and anchors it", () => { + const next = multiSelectReducer(INITIAL_STATE, { + type: "select_range", + taskId: "c", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["c"])); + expect(next.anchorId).toBe("c"); + }); + + it("selects the inclusive forward range from the anchor", () => { + const anchored = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "b" }); + const next = multiSelectReducer(anchored, { + type: "select_range", + taskId: "d", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["b", "c", "d"])); + expect(next.anchorId).toBe("b"); + }); + + it("selects the inclusive backward range from the anchor", () => { + const anchored = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "d" }); + const next = multiSelectReducer(anchored, { + type: "select_range", + taskId: "b", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["b", "c", "d"])); + }); + + it("unions the range with the existing selection", () => { + const withPrev = { ...INITIAL_STATE, selectedIds: new Set(["x"]), anchorId: "a" }; + const next = multiSelectReducer(withPrev, { + type: "select_range", + taskId: "c", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["x", "a", "b", "c"])); + }); + + it("falls back to single select when the anchor is in another column", () => { + const anchored = { ...INITIAL_STATE, selectedIds: new Set(["z"]), anchorId: "z" }; + const next = multiSelectReducer(anchored, { + type: "select_range", + taskId: "c", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["z", "c"])); + expect(next.anchorId).toBe("c"); + }); + + it("clearing the selection (set_selected empty) drops the anchor", () => { + const dirty = { ...INITIAL_STATE, selectedIds: new Set(["a"]), anchorId: "a" }; + const next = multiSelectReducer(dirty, { type: "set_selected", ids: new Set() }); + expect(next.anchorId).toBeNull(); + }); +}); diff --git a/apps/web/hooks/use-task-multi-select.test.ts b/apps/web/hooks/use-task-multi-select.test.ts index 64ba717c18..077fa6b9f1 100644 --- a/apps/web/hooks/use-task-multi-select.test.ts +++ b/apps/web/hooks/use-task-multi-select.test.ts @@ -8,6 +8,7 @@ describe("multiSelectReducer", () => { isMultiSelectEnabled: true, isDeleting: true, isArchiving: false, + anchorId: "a", }; expect(multiSelectReducer(dirty, { type: "reset" })).toBe(INITIAL_STATE); }); diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index af178ac66a..983c3dbfdd 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -5,7 +5,8 @@ import { useTaskActions } from "@/hooks/use-task-actions"; import { useAppStoreApi } from "@/components/state-provider"; import type { KanbanState } from "@/lib/state/slices"; -function useTaskMultiSelectStore() { +/** @internal Exported for reuse by the sidebar multi-select hook. */ +export function useTaskMultiSelectStore() { const store = useAppStoreApi(); const removeTasksFromStore = useCallback( @@ -170,11 +171,17 @@ type MultiSelectState = { isMultiSelectEnabled: boolean; isDeleting: boolean; isArchiving: boolean; + /** + * The task that anchors a shift-click range selection — the last task the + * user toggled/range-selected. `null` when there is no active anchor. + */ + anchorId: string | null; }; type MultiSelectAction = | { type: "reset" } | { type: "toggle_select"; taskId: string } + | { type: "select_range"; taskId: string; orderedIds: string[] } | { type: "set_selected"; ids: Set } | { type: "set_enabled"; value: boolean } | { type: "set_deleting"; value: boolean } @@ -186,8 +193,34 @@ export const INITIAL_STATE: MultiSelectState = { isMultiSelectEnabled: false, isDeleting: false, isArchiving: false, + anchorId: null, }; +/** + * Union-select every id from the anchor to `taskId` (inclusive) within + * `orderedIds`. When there is no valid anchor in `orderedIds` (first shift + * click, or anchor lives in a different column), fall back to selecting just + * `taskId` and making it the new anchor. + */ +function applyRangeSelect( + state: MultiSelectState, + taskId: string, + orderedIds: string[], +): MultiSelectState { + const anchor = state.anchorId; + const anchorIdx = anchor ? orderedIds.indexOf(anchor) : -1; + const targetIdx = orderedIds.indexOf(taskId); + if (anchorIdx === -1 || targetIdx === -1) { + const next = new Set(state.selectedIds); + next.add(taskId); + return { ...state, selectedIds: next, anchorId: taskId }; + } + const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]; + const next = new Set(state.selectedIds); + for (let i = lo; i <= hi; i++) next.add(orderedIds[i]); + return { ...state, selectedIds: next }; +} + /** @internal Exported for testing. */ export function multiSelectReducer( state: MultiSelectState, @@ -200,10 +233,16 @@ export function multiSelectReducer( const next = new Set(state.selectedIds); if (next.has(action.taskId)) next.delete(action.taskId); else next.add(action.taskId); - return { ...state, selectedIds: next }; + return { ...state, selectedIds: next, anchorId: action.taskId }; } + case "select_range": + return applyRangeSelect(state, action.taskId, action.orderedIds); case "set_selected": - return { ...state, selectedIds: action.ids }; + return { + ...state, + selectedIds: action.ids, + anchorId: action.ids.size ? state.anchorId : null, + }; case "set_enabled": return { ...state, isMultiSelectEnabled: action.value }; case "set_deleting": @@ -252,6 +291,12 @@ export function useTaskMultiSelect(workflowId: string | null) { [], ); + const selectRange = useCallback( + (taskId: string, orderedIds: string[]) => + dispatch({ type: "select_range", taskId, orderedIds }), + [], + ); + const enableMultiSelect = useCallback( () => setIsMultiSelectEnabled(true), [setIsMultiSelectEnabled], @@ -293,6 +338,7 @@ export function useTaskMultiSelect(workflowId: string | null) { enableMultiSelect, toggleMultiSelect, toggleSelect, + selectRange, clearSelection, bulkDelete, bulkArchive, From 78b2a1f25fd54ff3d7d6a416b7433070e724e7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 15:51:54 +0100 Subject: [PATCH 02/19] feat: cmd+click and shift+click multi-select on kanban board --- apps/web/components/kanban-board.tsx | 1 + apps/web/components/kanban-card-content.tsx | 2 +- apps/web/components/kanban-card.tsx | 60 ++++++++++++---- apps/web/components/kanban-column.tsx | 10 +++ .../components/kanban/swimlane-container.tsx | 4 ++ .../kanban/swimlane-kanban-content.tsx | 13 ++++ .../components/kanban/swipeable-columns.tsx | 71 +++++++++++-------- apps/web/lib/kanban/view-registry.ts | 1 + 8 files changed, 117 insertions(+), 45 deletions(-) diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 312c01abc2..68002dfcd0 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -394,6 +394,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB selectedRepositoryIds={s.userSettings.repositoryIds} selectedIds={s.multiSelect.selectedIds} onToggleSelect={s.multiSelect.toggleSelect} + onSelectRange={s.multiSelect.selectRange} isMultiSelectMode={s.multiSelect.isMultiSelectMode} onToggleMultiSelect={s.multiSelect.toggleMultiSelect} /> diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 301f246d57..2abec0720b 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -58,7 +58,7 @@ export type KanbanCardShellProps = KanbanCardActionProps & isSelected?: boolean; isMultiSelectMode?: boolean; isPreviewed: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onCheckboxClick: (e: React.MouseEvent) => void; }; diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index d6d8d419f7..6267c02f0d 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -82,6 +82,8 @@ interface KanbanCardProps { isSelected?: boolean; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + /** Shift-click range select within this card's column. */ + onRangeSelect?: (taskId: string) => void; isMultiSelectMode?: boolean; } @@ -251,6 +253,39 @@ function KanbanCardDialogs({ ); } +/** + * Cmd/Ctrl-click toggles a single card; Shift-click range-selects within the + * column; either modifier enters multi-select mode without the toggle button. + * A plain click toggles while in multi-select mode, otherwise previews/opens. + */ +function dispatchKanbanCardClick( + e: React.MouseEvent, + taskId: string, + task: Task, + handlers: { + onToggleSelect?: (taskId: string) => void; + onRangeSelect?: (taskId: string) => void; + onClick?: (task: Task) => void; + isMultiSelectMode?: boolean; + }, +): void { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + handlers.onToggleSelect?.(taskId); + return; + } + if (e.shiftKey) { + e.preventDefault(); + handlers.onRangeSelect?.(taskId); + return; + } + if (handlers.isMultiSelectMode) { + handlers.onToggleSelect?.(taskId); + return; + } + handlers.onClick?.(task); +} + function useActiveWorkspaceRepositories() { const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); return useAppStore((state) => @@ -274,6 +309,7 @@ export function KanbanCard({ isSelected, selectedIds, onToggleSelect, + onRangeSelect, isMultiSelectMode, }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ @@ -306,18 +342,13 @@ export function KanbanCard({ onMove, }); - const handleClick = () => { - if (isMultiSelectMode) { - onToggleSelect?.(task.id); - return; - } - onClick?.(task); - }; - - const handleCheckboxClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleSelect?.(task.id); - }; + const handleClick = (e: React.MouseEvent) => + dispatchKanbanCardClick(e, task.id, task, { + onToggleSelect, + onRangeSelect, + onClick, + isMultiSelectMode, + }); return ( <> @@ -338,7 +369,10 @@ export function KanbanCard({ isArchiving={isArchiving} menuEntries={dropdownMenuEntries} onClick={handleClick} - onCheckboxClick={handleCheckboxClick} + onCheckboxClick={(e) => { + e.stopPropagation(); + onToggleSelect?.(task.id); + }} onOpenFullPage={onOpenFullPage} /> diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index a6bff312f0..2d6598e4bf 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -34,6 +34,8 @@ interface KanbanColumnProps { hideHeader?: boolean; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + /** Shift-click range select; receives the clicked id + this column's ordered ids. */ + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; } @@ -53,6 +55,7 @@ export function KanbanColumn({ hideHeader = false, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ @@ -66,6 +69,10 @@ export function KanbanColumn({ [repositoriesByWorkspace], ); + // Ordered ids of the cards rendered in this column — the source of truth for + // shift-click range selection (matches exactly what the user sees). + const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]); + return (
onSelectRange(taskId, columnTaskIds) : undefined + } isMultiSelectMode={isMultiSelectMode} /> ))} diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index 678ad650a2..e22dd53def 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -48,6 +48,7 @@ export type SwimlaneContainerProps = { selectedRepositoryIds?: string[]; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; onToggleMultiSelect?: () => void; }; @@ -119,6 +120,7 @@ type WorkflowItemProps = { showMaximizeButton?: boolean; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; onToggleMultiSelect?: () => void; }; @@ -263,6 +265,7 @@ export function SwimlaneContainer({ selectedRepositoryIds = [], selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, onToggleMultiSelect, }: SwimlaneContainerProps) { @@ -332,6 +335,7 @@ export function SwimlaneContainer({ showMaximizeButton={showMaximizeButton} selectedIds={selectedIds} onToggleSelect={onToggleSelect} + onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} onToggleMultiSelect={index === 0 ? onToggleMultiSelect : undefined} /> diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index c97f172b1e..3dfa0a64db 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -41,6 +41,7 @@ export type SwimlaneKanbanContentProps = { showMaximizeButton?: boolean; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; }; @@ -185,6 +186,7 @@ function MobileKanbanLayout({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }: { steps: WorkflowStep[]; @@ -203,6 +205,7 @@ function MobileKanbanLayout({ archivingTaskId?: string | null; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; }) { const taskCounts = useMemo(() => { @@ -239,6 +242,7 @@ function MobileKanbanLayout({ archivingTaskId={archivingTaskId} selectedIds={selectedIds} onToggleSelect={onToggleSelect} + onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} /> @@ -260,6 +264,7 @@ function TabletKanbanLayout({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }: { steps: WorkflowStep[]; @@ -275,6 +280,7 @@ function TabletKanbanLayout({ archivingTaskId?: string | null; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; }) { const getTasksForStep = useTasksByStep(tasks); @@ -301,6 +307,7 @@ function TabletKanbanLayout({ archivingTaskId={archivingTaskId} selectedIds={selectedIds} onToggleSelect={onToggleSelect} + onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} />
@@ -323,6 +330,7 @@ function DesktopKanbanLayout({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, isCompactDesktop, }: { @@ -339,6 +347,7 @@ function DesktopKanbanLayout({ archivingTaskId?: string | null; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; isCompactDesktop: boolean; }) { @@ -369,6 +378,7 @@ function DesktopKanbanLayout({ showMaximizeButton={showMaximizeButton} selectedIds={selectedIds} onToggleSelect={onToggleSelect} + onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} /> ))} @@ -391,6 +401,7 @@ export function SwimlaneKanbanContent({ showMaximizeButton, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }: SwimlaneKanbanContentProps) { const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint(); @@ -416,6 +427,7 @@ export function SwimlaneKanbanContent({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }), [ @@ -432,6 +444,7 @@ export function SwimlaneKanbanContent({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, ], ); diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 62a5b3950a..25ed0c3bb0 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -22,9 +22,46 @@ type SwipeableColumnsProps = { archivingTaskId?: string | null; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; }; +/** Two-way sync between Embla's carousel position and the external activeIndex. */ +function useEmblaIndexSync( + emblaApi: ReturnType[1], + activeIndex: number, + onIndexChange: (index: number) => void, +) { + const userInteracting = useRef(false); + + // Sync carousel position with external activeIndex (tab clicks) + useEffect(() => { + if (emblaApi && emblaApi.selectedScrollSnap() !== activeIndex) { + emblaApi.scrollTo(activeIndex, true); + } + }, [emblaApi, activeIndex]); + + // Sync tab state when user swipes (only on user-initiated interactions) + useEffect(() => { + if (!emblaApi) return; + const onPointerDown = () => { + userInteracting.current = true; + }; + const onSelect = () => { + if (!userInteracting.current) return; + userInteracting.current = false; + const selectedIndex = emblaApi.selectedScrollSnap(); + if (selectedIndex !== activeIndex) onIndexChange(selectedIndex); + }; + emblaApi.on("pointerDown", onPointerDown); + emblaApi.on("select", onSelect); + return () => { + emblaApi.off("pointerDown", onPointerDown); + emblaApi.off("select", onSelect); + }; + }, [emblaApi, activeIndex, onIndexChange]); +} + export function SwipeableColumns({ steps, tasks, @@ -41,6 +78,7 @@ export function SwipeableColumns({ archivingTaskId, selectedIds, onToggleSelect, + onSelectRange, isMultiSelectMode, }: SwipeableColumnsProps) { // Stable options to avoid Embla reinitializing on every activeIndex change @@ -55,7 +93,6 @@ export function SwipeableColumns({ [initialIndex], ); const [emblaRef, emblaApi] = useEmblaCarousel(options); - const userInteracting = useRef(false); const getTasksForStep = useCallback( (stepId: string) => { @@ -67,36 +104,7 @@ export function SwipeableColumns({ [tasks], ); - // Sync carousel position with external activeIndex (tab clicks) - useEffect(() => { - if (emblaApi && emblaApi.selectedScrollSnap() !== activeIndex) { - emblaApi.scrollTo(activeIndex, true); - } - }, [emblaApi, activeIndex]); - - // Sync tab state when user swipes (only on user-initiated interactions) - useEffect(() => { - if (!emblaApi) return; - - const onPointerDown = () => { - userInteracting.current = true; - }; - const onSelect = () => { - if (!userInteracting.current) return; - userInteracting.current = false; - const selectedIndex = emblaApi.selectedScrollSnap(); - if (selectedIndex !== activeIndex) { - onIndexChange(selectedIndex); - } - }; - - emblaApi.on("pointerDown", onPointerDown); - emblaApi.on("select", onSelect); - return () => { - emblaApi.off("pointerDown", onPointerDown); - emblaApi.off("select", onSelect); - }; - }, [emblaApi, activeIndex, onIndexChange]); + useEmblaIndexSync(emblaApi, activeIndex, onIndexChange); // Use explicit height calculation for mobile since flex height inheritance doesn't work reliably // 100dvh - header (~56px) - tabs (~44px) - safe area @@ -127,6 +135,7 @@ export function SwipeableColumns({ archivingTaskId={archivingTaskId} selectedIds={selectedIds} onToggleSelect={onToggleSelect} + onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} hideHeader /> diff --git a/apps/web/lib/kanban/view-registry.ts b/apps/web/lib/kanban/view-registry.ts index 91a5f6199a..702ce78786 100644 --- a/apps/web/lib/kanban/view-registry.ts +++ b/apps/web/lib/kanban/view-registry.ts @@ -21,6 +21,7 @@ export type ViewContentProps = { showMaximizeButton?: boolean; selectedIds?: Set; onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; }; From eb2e38260c8e67b696ff0edd18ca0ae1cec349ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 15:52:06 +0100 Subject: [PATCH 03/19] feat: add cmd/shift multi-select to sidebar task list --- apps/web/components/task/task-item.tsx | 43 +++++-- .../task/task-session-sidebar-selection.tsx | 115 ++++++++++++++++++ .../components/task/task-session-sidebar.tsx | 10 ++ .../task/task-switcher-context-menu.tsx | 83 +++++++++++-- apps/web/components/task/task-switcher.tsx | 87 +++++++++++++ apps/web/hooks/use-sidebar-multi-select.ts | 86 +++++++++++++ .../lib/sidebar/apply-view-flatten.test.ts | 44 +++++++ apps/web/lib/sidebar/apply-view.ts | 26 ++++ 8 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 apps/web/components/task/task-session-sidebar-selection.tsx create mode 100644 apps/web/hooks/use-sidebar-multi-select.ts create mode 100644 apps/web/lib/sidebar/apply-view-flatten.test.ts diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 11e9f54bd4..4ed63a0719 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -39,7 +39,15 @@ type TaskItemProps = { sessionState?: TaskSessionState; isArchived?: boolean; isSelected?: boolean; + /** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */ + isMultiSelected?: boolean; onClick?: () => void; + /** + * Modifier-aware mouse handler. When provided, the row delegates click to this + * (cmd/shift/plain dispatch lives in the parent); `onClick` remains the plain + * keyboard-activation path. + */ + onSelect?: (e: React.MouseEvent) => void; diffStats?: DiffStats; isRemoteExecutor?: boolean; remoteExecutorType?: string; @@ -108,6 +116,28 @@ function handleTaskItemKeyDown(e: React.KeyboardEvent, onClick?: onClick?.(); } +function taskItemRowClassName( + isSelected: boolean, + isMultiSelected: boolean, + isRoot: boolean, +): string { + return cn( + "group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", + "transition-colors duration-75 hover:bg-foreground/[0.05]", + isSelected && "bg-primary/10", + isMultiSelected && "bg-primary/5 ring-1 ring-inset ring-primary/40", + isRoot && "pl-3", + ); +} + +/** Mouse uses the modifier-aware `onSelect`; falls back to the plain `onClick`. */ +function taskItemRowClick( + onSelect: ((e: React.MouseEvent) => void) | undefined, + onClick: (() => void) | undefined, +): (e: React.MouseEvent) => void { + return (e) => (onSelect ? onSelect(e) : onClick?.()); +} + function TaskStateIcon({ sessionState, state, @@ -347,7 +377,9 @@ export const TaskItem = memo(function TaskItem({ sessionState, isArchived, isSelected = false, + isMultiSelected = false, onClick, + onSelect, diffStats, isRemoteExecutor, remoteExecutorType, @@ -382,17 +414,14 @@ export const TaskItem = memo(function TaskItem({ role="button" tabIndex={0} data-testid="sidebar-task-item" + data-task-id={taskId} data-active={isSelected ? "true" : "false"} + data-multiselected={isMultiSelected ? "true" : undefined} aria-current={isSelected ? "true" : undefined} - onClick={onClick} + onClick={taskItemRowClick(onSelect, onClick)} onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)} style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined} - className={cn( - "group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", - "transition-colors duration-75 hover:bg-foreground/[0.05]", - isSelected && "bg-primary/10", - indent.depth === 0 && "pl-3", - )} + className={taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0)} > diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx new file mode 100644 index 0000000000..230763eebf --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSidebarMultiSelect } from "@/hooks/use-sidebar-multi-select"; +import { flattenVisibleTaskIds, type GroupedSidebarList } from "@/lib/sidebar/apply-view"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; + +type BulkArchiveState = { ids: string[]; executorTypes: Array }; + +/** + * Sidebar multi-select wiring: selection state, the visible-order list shift + * range-select walks, mixed-workflow detection (gates bulk "Move to step"), and + * the bulk-archive confirm dialog. Returns the props bundle for `TaskSwitcher` + * plus the dialog state the panel renders via `SidebarBulkArchiveDialog`. + */ +export function useSidebarSelection({ + workspaceId, + grouped, + collapsedGroups, + collapsedSubtaskParents, + displayTasks, +}: { + workspaceId: string | null; + grouped: GroupedSidebarList; + collapsedGroups: string[]; + collapsedSubtaskParents: string[]; + displayTasks: Array<{ id: string; workflowId?: string; remoteExecutorType?: string | null }>; +}) { + const multiSelect = useSidebarMultiSelect(workspaceId); + const { selectedIds, isSelecting, clearSelection, selectRange, toggleSelect, bulkArchive } = + multiSelect; + const [bulkArchiveState, setBulkArchiveState] = useState(null); + + const visibleTaskIds = useMemo( + () => flattenVisibleTaskIds(grouped, collapsedGroups, collapsedSubtaskParents), + [grouped, collapsedGroups, collapsedSubtaskParents], + ); + + const isMixedWorkflowSelection = useMemo(() => { + const wfIds = new Set(); + for (const t of displayTasks) { + if (selectedIds.has(t.id) && t.workflowId) wfIds.add(t.workflowId); + } + return wfIds.size > 1; + }, [displayTasks, selectedIds]); + + const handleBulkArchive = useCallback( + (ids: string[]) => { + const byId = new Map(displayTasks.map((t) => [t.id, t.remoteExecutorType])); + setBulkArchiveState({ ids, executorTypes: ids.map((id) => byId.get(id) ?? null) }); + }, + [displayTasks], + ); + + const handleBulkArchiveConfirm = useCallback( + async ({ cascade }: { cascade: boolean }) => { + if (!bulkArchiveState) return; + try { + await bulkArchive(bulkArchiveState.ids, { cascade }); + } catch (error) { + console.error("Failed to archive tasks:", error); + } finally { + setBulkArchiveState(null); + } + }, + [bulkArchiveState, bulkArchive], + ); + + // Escape clears an active selection. + useEffect(() => { + if (!isSelecting) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") clearSelection(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isSelecting, clearSelection]); + + const switcherProps = { + selectedTaskIds: selectedIds, + onToggleSelectTask: toggleSelect, + onSelectTaskRange: (taskId: string) => selectRange(taskId, visibleTaskIds), + onBulkArchive: handleBulkArchive, + isMixedWorkflowSelection, + }; + + return { + switcherProps, + bulkArchiveState, + setBulkArchiveState, + handleBulkArchiveConfirm, + isArchiving: multiSelect.isArchiving, + }; +} + +export function SidebarBulkArchiveDialog({ + selection, +}: { + selection: ReturnType; +}) { + if (!selection.bulkArchiveState) return null; + return ( + !o && selection.setBulkArchiveState(null)} + isBulkOperation + count={selection.bulkArchiveState.ids.length} + taskIds={selection.bulkArchiveState.ids} + executorTypes={selection.bulkArchiveState.executorTypes} + isArchiving={selection.isArchiving} + onConfirm={selection.handleBulkArchiveConfirm} + confirmTestId="sidebar-bulk-archive-confirm" + /> + ); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 7ab4916d31..7263d13eaa 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -15,6 +15,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; +import { useSidebarSelection, SidebarBulkArchiveDialog } from "./task-session-sidebar-selection"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { repositorySlug } from "@/lib/repository-slug"; @@ -629,6 +630,13 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed); const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks); const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs; + const selection = useSidebarSelection({ + workspaceId, + grouped, + collapsedGroups: effectiveView.collapsedGroups, + collapsedSubtaskParents, + displayTasks, + }); const handleToggleGroup = useCallback( (groupKey: string) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey), [toggleSidebarGroupCollapsed, effectiveView.id], @@ -661,9 +669,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} totalTaskCount={displayTasks.length} + {...selection.switcherProps} /> + ); }); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 7dea9afcf1..104b831edc 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -62,6 +62,11 @@ type ContextMenuProps = { onTogglePin?: (taskId: string) => void; isPinned?: boolean; isDeleting?: boolean; + /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ + selectedTaskIds?: Set; + onBulkArchive?: (taskIds: string[]) => void; + /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ + isMixedWorkflowSelection?: boolean; }; export function TaskItemWithContextMenu({ @@ -79,6 +84,9 @@ export function TaskItemWithContextMenu({ onTogglePin, isPinned, isDeleting, + selectedTaskIds, + onBulkArchive, + isMixedWorkflowSelection, }: ContextMenuProps) { const [contextOpen, setContextOpen] = useState(false); const [menuKey, setMenuKey] = useState(0); @@ -109,6 +117,9 @@ export function TaskItemWithContextMenu({ onTogglePin={onTogglePin} isPinned={isPinned} isDeleting={isDeleting} + selectedTaskIds={selectedTaskIds} + onBulkArchive={onBulkArchive} + isMixedWorkflowSelection={isMixedWorkflowSelection} closeMenu={closeMenu} moveTasks={moveTasks} /> @@ -136,9 +147,18 @@ function TaskContextMenuItems({ onTogglePin, isPinned, isDeleting, + selectedTaskIds, + onBulkArchive, + isMixedWorkflowSelection, closeMenu, moveTasks, }: TaskContextMenuItemsProps) { + // Right-clicking a task that's part of the active selection acts on the whole + // selection; right-clicking anything else acts on just that task. + const actingIds = + selectedTaskIds && selectedTaskIds.has(task.id) && selectedTaskIds.size > 1 + ? [...selectedTaskIds] + : [task.id]; return ( <> Duplicate - + @@ -234,13 +262,27 @@ function TaskRenameItem({ function TaskArchiveItem({ taskId, + actingIds, disabled, onArchiveTask, + onBulkArchive, }: { taskId: string; + actingIds: string[]; disabled?: boolean; onArchiveTask?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; }) { + const isBulk = actingIds.length > 1; + if (isBulk) { + if (!onBulkArchive) return null; + return ( + onBulkArchive(actingIds)}> + + Archive {actingIds.length} tasks + + ); + } if (!onArchiveTask) return null; return ( onArchiveTask(taskId)}> @@ -257,21 +299,48 @@ function TaskMoveItems({ steps, isDeleting, onMoveToStep, + actingIds, + isMixedWorkflowSelection, closeMenu, moveTasks, -}: Omit) { +}: Omit & { + actingIds: string[]; +}) { if (!task.workflowId) return null; + const workflowId = task.workflowId; + const isBulk = actingIds.length > 1; + const runBulkMove = (targetWorkflowId: string, stepId: string) => { + closeMenu(); + void moveTasks(actingIds, targetWorkflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }; + + // For a single task, keep the optimistic same-workflow move. For a bulk + // selection spanning workflows, "Move to step" of one workflow is ambiguous, + // so disable it (Send to workflow remains the explicit path). + let moveToStep: ((stepId: string) => void) | undefined; + if (isBulk) { + moveToStep = isMixedWorkflowSelection ? undefined : (stepId) => runBulkMove(workflowId, stepId); + } else { + moveToStep = selectMoveAction(task.id, workflowId, onMoveToStep, closeMenu); + } + return ( { + onMoveToStep={moveToStep} + onSendToWorkflow={(targetWorkflowId, stepId) => { + if (isBulk) { + runBulkMove(targetWorkflowId, stepId); + return; + } closeMenu(); - void moveTasks([task.id], workflowId, stepId).catch(() => { + void moveTasks([task.id], targetWorkflowId, stepId).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index e4052f9a6c..9f9fad91e7 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -69,8 +69,47 @@ type TaskSwitcherProps = { 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; + isMixedWorkflowSelection?: boolean; }; +/** + * 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. + */ +function dispatchSidebarRowClick( + e: React.MouseEvent, + taskId: string, + isSelecting: boolean, + handlers: { + onSelectTask: (taskId: string) => void; + onToggleSelectTask?: (taskId: string) => void; + onSelectTaskRange?: (taskId: string) => void; + }, +): void { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + handlers.onToggleSelectTask?.(taskId); + return; + } + if (e.shiftKey) { + e.preventDefault(); + handlers.onSelectTaskRange?.(taskId); + return; + } + if (isSelecting && handlers.onToggleSelectTask) { + handlers.onToggleSelectTask(taskId); + return; + } + handlers.onSelectTask(taskId); +} + type SubtaskToggleInfo = { subtaskCount: number; subtasksCollapsed: boolean; @@ -143,6 +182,11 @@ type TaskRowProps = { onTogglePin?: (taskId: string) => void; isPinned?: boolean; deletingTaskId?: string | null; + selectedTaskIds?: Set; + onToggleSelectTask?: (taskId: string) => void; + onSelectTaskRange?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; + isMixedWorkflowSelection?: boolean; }; function TaskRow({ @@ -164,8 +208,15 @@ function TaskRow({ onTogglePin, isPinned, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + isMixedWorkflowSelection, }: 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} @@ -343,6 +405,11 @@ type GroupSectionProps = { onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void; pinnedSet: Set; deletingTaskId?: string | null; + selectedTaskIds?: Set; + onToggleSelectTask?: (taskId: string) => void; + onSelectTaskRange?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; + isMixedWorkflowSelection?: boolean; }; function GroupSection({ @@ -369,6 +436,11 @@ function GroupSection({ onReorderSubtasks, pinnedSet, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + isMixedWorkflowSelection, }: GroupSectionProps) { const totalCount = countGroupTasks(group.tasks, subTasksByParentId); const ctx: TaskTreeContext = { @@ -390,6 +462,11 @@ function GroupSection({ onMoveToStep, onTogglePin, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + isMixedWorkflowSelection, }, onReorderGroup, onReorderSubtasks, @@ -437,6 +514,11 @@ export const TaskSwitcher = memo(function TaskSwitcher({ deletingTaskId, isLoading = false, totalTaskCount, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + isMixedWorkflowSelection, }: TaskSwitcherProps) { const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); if (isLoading) return ; @@ -478,6 +560,11 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onReorderSubtasks={onReorderSubtasks} pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} + selectedTaskIds={selectedTaskIds} + onToggleSelectTask={onToggleSelectTask} + onSelectTaskRange={onSelectTaskRange} + onBulkArchive={onBulkArchive} + isMixedWorkflowSelection={isMixedWorkflowSelection} /> ))} diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts new file mode 100644 index 0000000000..0515b05cfe --- /dev/null +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useEffect, useReducer, useState } from "react"; +import { + INITIAL_STATE, + multiSelectReducer, + useTaskMultiSelectStore, +} from "./use-task-multi-select"; +import { useTaskActions } from "./use-task-actions"; +import { useTaskWorkflowMove } from "./use-task-workflow-move"; + +/** + * Sidebar task multi-select. Reuses the shared selection reducer (toggle + + * shift-range with an anchor) from `use-task-multi-select`, but exposes only the + * pieces the sidebar needs: selection state plus bulk archive/move that act on + * an explicit id list (driven by the right-click context menu, not a toolbar). + * + * Unlike the kanban hook this spans all workflows in a workspace, so it resets + * on workspace change and the caller passes the target workflow for moves. + */ +export function useSidebarMultiSelect(workspaceId: string | null) { + const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE); + const { selectedIds } = state; + const [isArchiving, setIsArchiving] = useState(false); + + // Selection is ephemeral per workspace; drop it when the workspace changes. + useEffect(() => { + dispatch({ type: "reset" }); + }, [workspaceId]); + + const { archiveTaskById } = useTaskActions(); + const { removeTasksFromStore } = useTaskMultiSelectStore(); + const moveTasks = useTaskWorkflowMove(); + + const toggleSelect = useCallback( + (taskId: string) => dispatch({ type: "toggle_select", taskId }), + [], + ); + + const selectRange = useCallback( + (taskId: string, orderedIds: string[]) => + dispatch({ type: "select_range", taskId, orderedIds }), + [], + ); + + const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []); + + const bulkArchive = useCallback( + async (ids: string[], opts?: { cascade?: boolean }) => { + if (ids.length === 0) return; + setIsArchiving(true); + try { + const results = await Promise.allSettled(ids.map((id) => archiveTaskById(id, opts))); + const succeeded = new Set(ids.filter((_, i) => results[i].status === "fulfilled")); + removeTasksFromStore(succeeded); + } finally { + setIsArchiving(false); + clearSelection(); + } + }, + [archiveTaskById, removeTasksFromStore, clearSelection], + ); + + const bulkMove = useCallback( + async (ids: string[], targetWorkflowId: string, targetStepId: string) => { + if (ids.length === 0) return; + try { + await moveTasks(ids, targetWorkflowId, targetStepId); + } finally { + clearSelection(); + } + }, + [moveTasks, clearSelection], + ); + + return { + selectedIds, + isSelecting: selectedIds.size > 0, + isArchiving, + toggleSelect, + selectRange, + clearSelection, + bulkArchive, + bulkMove, + }; +} diff --git a/apps/web/lib/sidebar/apply-view-flatten.test.ts b/apps/web/lib/sidebar/apply-view-flatten.test.ts new file mode 100644 index 0000000000..d0293c0d66 --- /dev/null +++ b/apps/web/lib/sidebar/apply-view-flatten.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import { flattenVisibleTaskIds, type GroupedSidebarList } from "./apply-view"; + +function task(id: string): TaskSwitcherItem { + return { id, title: id }; +} + +function grouped( + groups: GroupedSidebarList["groups"], + subs: Record = {}, +): GroupedSidebarList { + return { groups, subTasksByParentId: new Map(Object.entries(subs)) }; +} + +describe("flattenVisibleTaskIds", () => { + it("walks groups then tasks in render order", () => { + const g = grouped([ + { key: "g1", label: "G1", tasks: [task("a"), task("b")] }, + { key: "g2", label: "G2", tasks: [task("c")] }, + ]); + expect(flattenVisibleTaskIds(g, [], [])).toEqual(["a", "b", "c"]); + }); + + it("expands subtasks depth-first under their parent", () => { + const g = grouped([{ key: "g1", label: "G1", tasks: [task("a"), task("d")] }], { + a: [task("a1"), task("a2")], + }); + expect(flattenVisibleTaskIds(g, [], [])).toEqual(["a", "a1", "a2", "d"]); + }); + + it("skips tasks in collapsed groups", () => { + const g = grouped([ + { key: "g1", label: "G1", tasks: [task("a")] }, + { key: "g2", label: "G2", tasks: [task("b")] }, + ]); + expect(flattenVisibleTaskIds(g, ["g2"], [])).toEqual(["a"]); + }); + + it("hides subtasks of a collapsed parent but keeps the parent", () => { + const g = grouped([{ key: "g1", label: "G1", tasks: [task("a")] }], { a: [task("a1")] }); + expect(flattenVisibleTaskIds(g, [], ["a"])).toEqual(["a"]); + }); +}); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index e7fa54da2c..52d5e5ead6 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -458,6 +458,32 @@ export function countGroupTasks( return total; } +/** + * Flatten the grouped sidebar tree into the exact top-to-bottom order of the + * task rows the user currently sees, honoring collapsed groups and collapsed + * subtask parents. This is the ordered list shift-click range selection walks. + */ +export function flattenVisibleTaskIds( + grouped: GroupedSidebarList, + collapsedGroupKeys: string[], + collapsedSubtaskParentIds: string[], +): string[] { + const collapsedGroups = new Set(collapsedGroupKeys); + const collapsedSubs = new Set(collapsedSubtaskParentIds); + const out: string[] = []; + const visit = (task: TaskSwitcherItem) => { + out.push(task.id); + if (collapsedSubs.has(task.id)) return; + const subs = grouped.subTasksByParentId.get(task.id); + if (subs) for (const sub of subs) visit(sub); + }; + for (const group of grouped.groups) { + if (collapsedGroups.has(group.key)) continue; + for (const task of group.tasks) visit(task); + } + return out; +} + export function applyView( tasks: TaskSwitcherItem[], view: SidebarView, From 60df474fdab878adb191d5cf451ef17d0ab9169b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 15:52:31 +0100 Subject: [PATCH 04/19] test: e2e coverage for cmd/shift multi-select --- apps/web/e2e/pages/kanban-page.ts | 31 +++ apps/web/e2e/pages/sidebar-tasks-page.ts | 65 ++++++ .../kanban/cmd-shift-multi-select.spec.ts | 190 +++++++++++++++++ .../tests/task/sidebar-multi-select.spec.ts | 198 ++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 apps/web/e2e/pages/sidebar-tasks-page.ts create mode 100644 apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts create mode 100644 apps/web/e2e/tests/task/sidebar-multi-select.spec.ts diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts index b4c092ad2e..75c1bb0556 100644 --- a/apps/web/e2e/pages/kanban-page.ts +++ b/apps/web/e2e/pages/kanban-page.ts @@ -192,6 +192,37 @@ export class KanbanPage { await this.taskSelectCheckbox(taskId).click(); } + /** Cmd/Ctrl-click a card body — toggles it into the selection without the toggle button. */ + async cmdClickCard(taskId: string) { + const card = this.taskCard(taskId); + await card.waitFor({ state: "visible" }); + await card.click({ modifiers: ["ControlOrMeta"] }); + } + + /** Shift-click a card body — range-selects within the column. */ + async shiftClickCard(taskId: string) { + const card = this.taskCard(taskId); + await card.waitFor({ state: "visible" }); + await card.click({ modifiers: ["Shift"] }); + } + + /** Plain click a card body (no modifier). */ + async plainClickCard(taskId: string) { + const card = this.taskCard(taskId); + await card.waitFor({ state: "visible" }); + await card.click(); + } + + /** A card is part of the selection when it renders the primary ring. */ + async expectCardSelected(taskId: string, selected = true) { + const card = this.taskCard(taskId); + if (selected) { + await expect(card).toHaveClass(/ring-primary/, { timeout: 5_000 }); + } else { + await expect(card).not.toHaveClass(/ring-primary/, { timeout: 5_000 }); + } + } + pipelineTask(taskId: string): Locator { return this.page.getByTestId(`pipeline-task-${taskId}`); } diff --git a/apps/web/e2e/pages/sidebar-tasks-page.ts b/apps/web/e2e/pages/sidebar-tasks-page.ts new file mode 100644 index 0000000000..2d68734f55 --- /dev/null +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -0,0 +1,65 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Page object for the task list inside the unified AppSidebar (TaskSessionSidebar). + * Covers cmd/shift-click multi-select and the selection-aware right-click menu. + */ +export class SidebarTasksPage { + readonly root: Locator; + + constructor(private readonly page: Page) { + this.root = page.getByTestId("task-sidebar"); + } + + row(taskId: string): Locator { + return this.root.locator(`[data-testid='sidebar-task-item'][data-task-id='${taskId}']`); + } + + rows(): Locator { + return this.root.locator("[data-testid='sidebar-task-item']"); + } + + async cmdClick(taskId: string) { + await this.row(taskId).click({ modifiers: ["ControlOrMeta"] }); + } + + async shiftClick(taskId: string) { + await this.row(taskId).click({ modifiers: ["Shift"] }); + } + + async plainClick(taskId: string) { + await this.row(taskId).click(); + } + + async rightClick(taskId: string) { + await this.row(taskId).click({ button: "right" }); + } + + async expectSelected(taskId: string, selected = true) { + const row = this.row(taskId); + if (selected) { + await expect(row).toHaveAttribute("data-multiselected", "true", { timeout: 5_000 }); + } else { + await expect(row).not.toHaveAttribute("data-multiselected", "true", { timeout: 5_000 }); + } + } + + async selectedCount(): Promise { + return this.root + .locator("[data-testid='sidebar-task-item'][data-multiselected='true']") + .count(); + } + + // --- right-click bulk menu --- + bulkArchiveMenuItem(count: number): Locator { + return this.page.getByRole("menuitem", { name: `Archive ${count} tasks` }); + } + + singleArchiveMenuItem(): Locator { + return this.page.getByRole("menuitem", { name: "Archive", exact: true }); + } + + bulkArchiveConfirm(): Locator { + return this.page.getByTestId("sidebar-bulk-archive-confirm"); + } +} diff --git a/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts b/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts new file mode 100644 index 0000000000..97c191acdc --- /dev/null +++ b/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts @@ -0,0 +1,190 @@ +/** + * E2E: cmd/ctrl-click and shift-click multi-select on the Kanban board. + * + * Covers the modifier-driven entry into multi-select mode (no toggle button + * needed), per-card toggle, same-column shift range, the cross-column fallback, + * and plain-click toggle behaviour while in multi-select mode. + */ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +/** Rendered (display-order) task ids inside a column. */ +async function columnTaskIds(page: Page, stepId: string): Promise { + return page + .getByTestId(`kanban-column-${stepId}`) + .locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])") + .evaluateAll((els: Element[]) => + els.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")), + ); +} + +/** Task ids of the selected (primary-ring) cards in a column. */ +async function columnSelectedIds(page: Page, stepId: string): Promise { + return page + .getByTestId(`kanban-column-${stepId}`) + .locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])") + .evaluateAll((els: Element[]) => + els + .filter((e) => e.className.includes("ring-primary")) + .map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")), + ); +} + +test.describe("Kanban cmd/shift multi-select", () => { + test("cmd-click enters multi-select and shows the toolbar without the toggle", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Cmd Enter Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await expect(kanban.multiSelectToolbar).not.toBeVisible(); + + await kanban.cmdClickCard(task.id); + + await expect(kanban.multiSelectToolbar).toBeVisible(); + await expect(kanban.multiSelectToolbar).toContainText("1 selected"); + await kanban.expectCardSelected(task.id); + }); + + test("cmd-click toggles individual cards in and out of the selection", async ({ + testPage, + apiClient, + seedData, + }) => { + const t1 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const t2 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.cmdClickCard(t1.id); + await kanban.cmdClickCard(t2.id); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + + // Cmd-click an already-selected card removes it from the selection. + await kanban.cmdClickCard(t1.id); + await expect(kanban.multiSelectToolbar).toContainText("1 selected"); + await kanban.expectCardSelected(t1.id, false); + await kanban.expectCardSelected(t2.id, true); + }); + + test("shift-click selects a contiguous range within the column", async ({ + testPage, + apiClient, + seedData, + }) => { + // Four tasks in the same column; create sequentially for a stable order. + for (const title of ["Range A", "Range B", "Range C", "Range D"]) { + await apiClient.createTask(seedData.workspaceId, title, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + } + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.taskCardByTitle("Range A")).toBeVisible({ timeout: 10_000 }); + + const order = await columnTaskIds(testPage, seedData.startStepId); + expect(order.length).toBeGreaterThanOrEqual(4); + + // Anchor on the first card, shift-click the third → first three selected. + await kanban.cmdClickCard(order[0]); + await kanban.shiftClickCard(order[2]); + + await expect(kanban.multiSelectToolbar).toContainText("3 selected"); + const selected = await columnSelectedIds(testPage, seedData.startStepId); + expect(selected.sort()).toEqual(order.slice(0, 3).sort()); + await kanban.expectCardSelected(order[3], false); + }); + + test("shift-click does not extend the range across columns", async ({ + testPage, + apiClient, + seedData, + }) => { + const otherStep = seedData.steps.find((s) => s.id !== seedData.startStepId); + test.skip(!otherStep, "Workflow needs at least two steps"); + + const a1 = await apiClient.createTask(seedData.workspaceId, "Col A One", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "Col A Two", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const b1 = await apiClient.createTask(seedData.workspaceId, "Col B One", { + workflow_id: seedData.workflowId, + workflow_step_id: otherStep!.id, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.taskCard(b1.id)).toBeVisible({ timeout: 10_000 }); + + // Anchor in column A, shift-click into column B. The anchor isn't in B's + // column order, so it falls back to selecting just the clicked card. + await kanban.cmdClickCard(a1.id); + await kanban.shiftClickCard(b1.id); + + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + await kanban.expectCardSelected(a1.id, true); + await kanban.expectCardSelected(b1.id, true); + }); + + test("plain click toggles selection while in multi-select mode", async ({ + testPage, + apiClient, + seedData, + }) => { + const t1 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const t2 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Enter mode via cmd-click, then plain-click another card to add it. + await kanban.cmdClickCard(t1.id); + await kanban.plainClickCard(t2.id); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + // Still on the board — a plain in-mode click must not navigate away. + await expect(kanban.board).toBeVisible(); + + // Plain-click the same card again toggles it back out. + await kanban.plainClickCard(t2.id); + await expect(kanban.multiSelectToolbar).toContainText("1 selected"); + }); + + test("the multi-select toggle button still works alongside cmd-click", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Toggle Coexist", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.taskCardByTitle("Toggle Coexist")).toBeVisible({ timeout: 10_000 }); + + await kanban.multiSelectToggle.first().click(); + await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts new file mode 100644 index 0000000000..cfe67868a7 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts @@ -0,0 +1,198 @@ +/** + * E2E: cmd/ctrl-click and shift-click multi-select in the task sidebar, plus the + * selection-aware right-click context menu (bulk archive / single fallback). + */ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { SidebarTasksPage } from "../../pages/sidebar-tasks-page"; + +type Created = { id: string; title: string }; + +async function seedTasks( + apiClient: { createTask: (ws: string, title: string, opts: object) => Promise<{ id: string }> }, + seedData: { workspaceId: string; workflowId: string; startStepId: string }, + titles: string[], +): Promise { + const out: Created[] = []; + for (const title of titles) { + const t = await apiClient.createTask(seedData.workspaceId, title, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + out.push({ id: t.id, title }); + } + return out; +} + +/** Rendered top-to-bottom order of task ids in the sidebar. */ +async function sidebarOrder(page: Page): Promise { + return page + .getByTestId("task-sidebar") + .locator("[data-testid='sidebar-task-item']") + .evaluateAll((els: Element[]) => els.map((e) => e.getAttribute("data-task-id") ?? "")); +} + +async function openSidebarOn(page: Page, taskId: string): Promise { + await page.goto(`/t/${taskId}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await expect(session.sidebar).toBeVisible({ timeout: 10_000 }); + return new SidebarTasksPage(page); +} + +test.describe("Sidebar cmd/shift multi-select", () => { + test("cmd-click selects a row without navigating", async ({ testPage, apiClient, seedData }) => { + const [nav, target] = await seedTasks(apiClient, seedData, ["SB Nav", "SB Target"]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(target.id); + + await sidebar.expectSelected(target.id, true); + // URL must not change — cmd-click selects, it doesn't open the task. + await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`)); + }); + + test("cmd-click toggles multiple rows in and out", async ({ testPage, apiClient, seedData }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB MultiNav", "SB A", "SB B"]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + expect(await sidebar.selectedCount()).toBe(2); + + await sidebar.cmdClick(a.id); + await sidebar.expectSelected(a.id, false); + await sidebar.expectSelected(b.id, true); + expect(await sidebar.selectedCount()).toBe(1); + }); + + test("shift-click selects a contiguous range of visible rows", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav] = await seedTasks(apiClient, seedData, [ + "SB Range Nav", + "SB Range 1", + "SB Range 2", + "SB Range 3", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + await expect(sidebar.rows().first()).toBeVisible({ timeout: 10_000 }); + + const order = await sidebarOrder(testPage); + expect(order.length).toBeGreaterThanOrEqual(4); + + await sidebar.cmdClick(order[0]); + await sidebar.shiftClick(order[2]); + + await sidebar.expectSelected(order[0], true); + await sidebar.expectSelected(order[1], true); + await sidebar.expectSelected(order[2], true); + await sidebar.expectSelected(order[3], false); + }); + + test("plain click navigates when nothing is selected", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, target] = await seedTasks(apiClient, seedData, ["SB PlainNav", "SB PlainTarget"]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.plainClick(target.id); + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}`)); + }); + + test("plain click toggles while a selection is active", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB PT Nav", "SB PT A", "SB PT B"]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); // enter selection mode + await sidebar.plainClick(b.id); // plain click now toggles, not navigates + await sidebar.expectSelected(b.id, true); + await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`)); + expect(await sidebar.selectedCount()).toBe(2); + }); + + test("Escape clears the selection", async ({ testPage, apiClient, seedData }) => { + const [nav, a] = await seedTasks(apiClient, seedData, ["SB Esc Nav", "SB Esc A"]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.expectSelected(a.id, true); + + await testPage.keyboard.press("Escape"); + await sidebar.expectSelected(a.id, false); + expect(await sidebar.selectedCount()).toBe(0); + }); +}); + +test.describe("Sidebar selection-aware context menu", () => { + test("right-clicking a selected row archives the whole selection", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, [ + "SB Arc Nav", + "SB Arc A", + "SB Arc B", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + expect(await sidebar.selectedCount()).toBe(2); + + await sidebar.rightClick(a.id); + const bulkItem = sidebar.bulkArchiveMenuItem(2); + await expect(bulkItem).toBeVisible({ timeout: 5_000 }); + await bulkItem.click(); + + const confirm = sidebar.bulkArchiveConfirm(); + await expect(confirm).toBeVisible({ timeout: 5_000 }); + await confirm.click(); + + await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 }); + }); + + test("right-clicking an unselected row acts on that row only", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, a, b, c] = await seedTasks(apiClient, seedData, [ + "SB Solo Nav", + "SB Solo A", + "SB Solo B", + "SB Solo C", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + expect(await sidebar.selectedCount()).toBe(2); + + // Right-click a row that is NOT in the selection. + await sidebar.rightClick(c.id); + + // The menu must offer the single-task action, not the bulk one. + await expect(sidebar.singleArchiveMenuItem()).toBeVisible({ timeout: 5_000 }); + await expect(sidebar.bulkArchiveMenuItem(2)).not.toBeVisible(); + + // Opening the menu on an unselected row leaves the existing selection intact + // (asserted while the menu is open — Escape both closes the menu and clears + // the selection, which is the documented dismiss behaviour). + await sidebar.expectSelected(a.id, true); + await sidebar.expectSelected(b.id, true); + + await testPage.keyboard.press("Escape"); + }); +}); From bf3b1e61f924475dfc9d6f933ccc29366f22d773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 16:30:47 +0100 Subject: [PATCH 05/19] fix: harden multi-select selection lifecycle from review feedback --- apps/web/components/kanban-card-click.test.ts | 64 +++++++++++++++++++ apps/web/components/kanban-card.tsx | 3 +- apps/web/components/task/task-item.tsx | 6 +- .../task/task-session-sidebar-selection.tsx | 21 +++++- .../task/task-switcher-click.test.ts | 53 +++++++++++++++ .../task/task-switcher-context-menu.tsx | 58 +++++++++++------ apps/web/components/task/task-switcher.tsx | 23 +++++-- apps/web/hooks/use-sidebar-multi-select.ts | 40 ++++++++++-- .../hooks/use-task-multi-select-range.test.ts | 33 +++++++++- apps/web/hooks/use-task-multi-select.ts | 35 +++++++--- 10 files changed, 291 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/kanban-card-click.test.ts create mode 100644 apps/web/components/task/task-switcher-click.test.ts diff --git a/apps/web/components/kanban-card-click.test.ts b/apps/web/components/kanban-card-click.test.ts new file mode 100644 index 0000000000..b9ac79c613 --- /dev/null +++ b/apps/web/components/kanban-card-click.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import { dispatchKanbanCardClick, type Task } from "./kanban-card"; + +function fakeEvent(mods: Partial = {}) { + return { + metaKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...mods, + } as unknown as React.MouseEvent; +} + +const task = { id: "t1", title: "T", workflowStepId: "s1" } as Task; + +function handlers() { + return { + onToggleSelect: vi.fn(), + onRangeSelect: vi.fn(), + onClick: vi.fn(), + isMultiSelectMode: false, + }; +} + +describe("dispatchKanbanCardClick", () => { + it("cmd/meta-click toggles and prevents default", () => { + const h = handlers(); + const e = fakeEvent({ metaKey: true }); + dispatchKanbanCardClick(e, task.id, task, h); + expect(e.preventDefault).toHaveBeenCalled(); + expect(h.onToggleSelect).toHaveBeenCalledWith("t1"); + expect(h.onClick).not.toHaveBeenCalled(); + expect(h.onRangeSelect).not.toHaveBeenCalled(); + }); + + it("ctrl-click toggles (Windows/Linux)", () => { + const h = handlers(); + dispatchKanbanCardClick(fakeEvent({ ctrlKey: true }), task.id, task, h); + expect(h.onToggleSelect).toHaveBeenCalledWith("t1"); + }); + + it("shift-click range-selects and prevents default", () => { + const h = handlers(); + const e = fakeEvent({ shiftKey: true }); + dispatchKanbanCardClick(e, task.id, task, h); + expect(e.preventDefault).toHaveBeenCalled(); + expect(h.onRangeSelect).toHaveBeenCalledWith("t1"); + expect(h.onToggleSelect).not.toHaveBeenCalled(); + }); + + it("plain click while in multi-select mode toggles", () => { + const h = { ...handlers(), isMultiSelectMode: true }; + dispatchKanbanCardClick(fakeEvent(), task.id, task, h); + expect(h.onToggleSelect).toHaveBeenCalledWith("t1"); + expect(h.onClick).not.toHaveBeenCalled(); + }); + + it("plain click outside multi-select mode previews/opens", () => { + const h = handlers(); + dispatchKanbanCardClick(fakeEvent(), task.id, task, h); + expect(h.onClick).toHaveBeenCalledWith(task); + expect(h.onToggleSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 6267c02f0d..0058cdb41a 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -258,7 +258,8 @@ function KanbanCardDialogs({ * column; either modifier enters multi-select mode without the toggle button. * A plain click toggles while in multi-select mode, otherwise previews/opens. */ -function dispatchKanbanCardClick( +/** @internal Exported for unit testing the four-branch click dispatch. */ +export function dispatchKanbanCardClick( e: React.MouseEvent, taskId: string, task: Task, diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 4ed63a0719..7f5cf3b2be 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -125,7 +125,10 @@ function taskItemRowClassName( "group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", "transition-colors duration-75 hover:bg-foreground/[0.05]", isSelected && "bg-primary/10", - isMultiSelected && "bg-primary/5 ring-1 ring-inset ring-primary/40", + // When a row is both the active task and multi-selected, keep the stronger + // active background and just add the selection ring on top. + isMultiSelected && !isSelected && "bg-primary/5", + isMultiSelected && "ring-1 ring-inset ring-primary/40", isRoot && "pl-3", ); } @@ -414,7 +417,6 @@ export const TaskItem = memo(function TaskItem({ role="button" tabIndex={0} data-testid="sidebar-task-item" - data-task-id={taskId} data-active={isSelected ? "true" : "false"} data-multiselected={isMultiSelected ? "true" : undefined} aria-current={isSelected ? "true" : undefined} diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 230763eebf..2d6bf4a8a4 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -27,8 +27,15 @@ export function useSidebarSelection({ displayTasks: Array<{ id: string; workflowId?: string; remoteExecutorType?: string | null }>; }) { const multiSelect = useSidebarMultiSelect(workspaceId); - const { selectedIds, isSelecting, clearSelection, selectRange, toggleSelect, bulkArchive } = - multiSelect; + const { + selectedIds, + isSelecting, + clearSelection, + selectRange, + toggleSelect, + bulkArchive, + bulkMove, + } = multiSelect; const [bulkArchiveState, setBulkArchiveState] = useState(null); const visibleTaskIds = useMemo( @@ -76,11 +83,19 @@ export function useSidebarSelection({ return () => window.removeEventListener("keydown", onKey); }, [isSelecting, clearSelection]); + // Stable ref so TaskSwitcher's React.memo isn't defeated by a fresh closure + // on every unrelated sidebar re-render. + const onSelectTaskRange = useCallback( + (taskId: string) => selectRange(taskId, visibleTaskIds), + [selectRange, visibleTaskIds], + ); + const switcherProps = { selectedTaskIds: selectedIds, onToggleSelectTask: toggleSelect, - onSelectTaskRange: (taskId: string) => selectRange(taskId, visibleTaskIds), + onSelectTaskRange, onBulkArchive: handleBulkArchive, + onBulkMove: bulkMove, isMixedWorkflowSelection, }; diff --git a/apps/web/components/task/task-switcher-click.test.ts b/apps/web/components/task/task-switcher-click.test.ts new file mode 100644 index 0000000000..92179a4162 --- /dev/null +++ b/apps/web/components/task/task-switcher-click.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { dispatchSidebarRowClick } from "./task-switcher"; + +function fakeEvent(mods: Partial = {}) { + return { + metaKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...mods, + } as unknown as React.MouseEvent; +} + +function handlers() { + return { + onSelectTask: vi.fn(), + onToggleSelectTask: vi.fn(), + onSelectTaskRange: vi.fn(), + }; +} + +describe("dispatchSidebarRowClick", () => { + it("cmd/meta-click toggles and prevents default", () => { + const h = handlers(); + const e = fakeEvent({ metaKey: true }); + dispatchSidebarRowClick(e, "t1", false, h); + expect(e.preventDefault).toHaveBeenCalled(); + expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1"); + expect(h.onSelectTask).not.toHaveBeenCalled(); + }); + + it("shift-click range-selects and prevents default", () => { + const h = handlers(); + const e = fakeEvent({ shiftKey: true }); + dispatchSidebarRowClick(e, "t1", false, h); + expect(e.preventDefault).toHaveBeenCalled(); + expect(h.onSelectTaskRange).toHaveBeenCalledWith("t1"); + }); + + it("plain click toggles while a selection is active", () => { + const h = handlers(); + dispatchSidebarRowClick(fakeEvent(), "t1", true, h); + expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1"); + expect(h.onSelectTask).not.toHaveBeenCalled(); + }); + + it("plain click navigates when nothing is selected", () => { + const h = handlers(); + dispatchSidebarRowClick(fakeEvent(), "t1", false, h); + expect(h.onSelectTask).toHaveBeenCalledWith("t1"); + expect(h.onToggleSelectTask).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 104b831edc..99cd10d28e 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -65,6 +65,7 @@ type ContextMenuProps = { /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ selectedTaskIds?: Set; onBulkArchive?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ isMixedWorkflowSelection?: boolean; }; @@ -86,6 +87,7 @@ export function TaskItemWithContextMenu({ isDeleting, selectedTaskIds, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, }: ContextMenuProps) { const [contextOpen, setContextOpen] = useState(false); @@ -119,6 +121,7 @@ export function TaskItemWithContextMenu({ isDeleting={isDeleting} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} + onBulkMove={onBulkMove} isMixedWorkflowSelection={isMixedWorkflowSelection} closeMenu={closeMenu} moveTasks={moveTasks} @@ -149,16 +152,16 @@ function TaskContextMenuItems({ isDeleting, selectedTaskIds, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, closeMenu, moveTasks, }: TaskContextMenuItemsProps) { - // Right-clicking a task that's part of the active selection acts on the whole - // selection; right-clicking anything else acts on just that task. - const actingIds = - selectedTaskIds && selectedTaskIds.has(task.id) && selectedTaskIds.size > 1 - ? [...selectedTaskIds] - : [task.id]; + // Right-clicking any row that's part of the active selection acts on the whole + // selection (even a one-row selection, so the action clears it); right-clicking + // a non-selected row acts on just that task and leaves the selection intact. + const actingOnSelection = !!selectedTaskIds?.has(task.id); + const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id]; return ( <> void; onBulkArchive?: (taskIds: string[]) => void; }) { - const isBulk = actingIds.length > 1; - if (isBulk) { - if (!onBulkArchive) return null; + // Acting on the selection routes through the bulk path (which clears the + // selection afterwards) even for a single selected row. + if (actingOnSelection && onBulkArchive) { + const n = actingIds.length; return ( onBulkArchive(actingIds)}> - Archive {actingIds.length} tasks + {n > 1 ? `Archive ${n} tasks` : "Archive"} ); } @@ -300,28 +309,39 @@ function TaskMoveItems({ isDeleting, onMoveToStep, actingIds, + actingOnSelection, + onBulkMove, isMixedWorkflowSelection, closeMenu, moveTasks, }: Omit & { actingIds: string[]; + actingOnSelection: boolean; }) { if (!task.workflowId) return null; const workflowId = task.workflowId; - const isBulk = actingIds.length > 1; - const runBulkMove = (targetWorkflowId: string, stepId: string) => { + // Moving a selection routes through the sidebar hook's bulkMove, which clears + // the selection afterwards. Fall back to a raw move when no bulk handler is + // wired (e.g. the kanban-less callers that don't manage a selection). + const runSelectionMove = (targetWorkflowId: string, stepId: string) => { closeMenu(); + if (onBulkMove) { + onBulkMove(actingIds, targetWorkflowId, stepId); + return; + } void moveTasks(actingIds, targetWorkflowId, stepId).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }; - // For a single task, keep the optimistic same-workflow move. For a bulk - // selection spanning workflows, "Move to step" of one workflow is ambiguous, - // so disable it (Send to workflow remains the explicit path). + // Single-task right-click keeps the optimistic same-workflow move. A selection + // spanning workflows makes "Move to step" of one workflow ambiguous, so disable + // it there (Send to workflow remains the explicit path). let moveToStep: ((stepId: string) => void) | undefined; - if (isBulk) { - moveToStep = isMixedWorkflowSelection ? undefined : (stepId) => runBulkMove(workflowId, stepId); + if (actingOnSelection) { + moveToStep = isMixedWorkflowSelection + ? undefined + : (stepId) => runSelectionMove(workflowId, stepId); } else { moveToStep = selectMoveAction(task.id, workflowId, onMoveToStep, closeMenu); } @@ -335,8 +355,8 @@ function TaskMoveItems({ disabled={isDeleting || task.isArchived} onMoveToStep={moveToStep} onSendToWorkflow={(targetWorkflowId, stepId) => { - if (isBulk) { - runBulkMove(targetWorkflowId, stepId); + if (actingOnSelection) { + runSelectionMove(targetWorkflowId, stepId); return; } closeMenu(); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 9f9fad91e7..9244a2a9a0 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -75,6 +75,7 @@ type TaskSwitcherProps = { onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; isMixedWorkflowSelection?: boolean; }; @@ -83,7 +84,8 @@ type TaskSwitcherProps = { * range, a plain click toggles while a selection is active and otherwise * navigates to the task. */ -function dispatchSidebarRowClick( +/** @internal Exported for unit testing the modifier-aware click dispatch. */ +export function dispatchSidebarRowClick( e: React.MouseEvent, taskId: string, isSelecting: boolean, @@ -93,14 +95,17 @@ function dispatchSidebarRowClick( onSelectTaskRange?: (taskId: string) => void; }, ): void { - if (e.metaKey || e.ctrlKey) { + // 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); + handlers.onToggleSelectTask(taskId); return; } - if (e.shiftKey) { + if (e.shiftKey && handlers.onSelectTaskRange) { e.preventDefault(); - handlers.onSelectTaskRange?.(taskId); + handlers.onSelectTaskRange(taskId); return; } if (isSelecting && handlers.onToggleSelectTask) { @@ -186,6 +191,7 @@ type TaskRowProps = { onToggleSelectTask?: (taskId: string) => void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; isMixedWorkflowSelection?: boolean; }; @@ -212,6 +218,7 @@ function TaskRow({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, }: TaskRowProps) { const isSelected = task.id === selectedTaskId || task.id === activeTaskId; @@ -235,6 +242,7 @@ function TaskRow({ isDeleting={deletingTaskId === task.id} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} + onBulkMove={onBulkMove} isMixedWorkflowSelection={isMixedWorkflowSelection} > void; onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; isMixedWorkflowSelection?: boolean; }; @@ -440,6 +449,7 @@ function GroupSection({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, }: GroupSectionProps) { const totalCount = countGroupTasks(group.tasks, subTasksByParentId); @@ -466,6 +476,7 @@ function GroupSection({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, }, onReorderGroup, @@ -518,6 +529,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkMove, isMixedWorkflowSelection, }: TaskSwitcherProps) { const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); @@ -564,6 +576,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onToggleSelectTask={onToggleSelectTask} onSelectTaskRange={onSelectTaskRange} onBulkArchive={onBulkArchive} + onBulkMove={onBulkMove} isMixedWorkflowSelection={isMixedWorkflowSelection} /> ))} diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index 0515b05cfe..15d58a7cd6 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -6,8 +6,10 @@ import { multiSelectReducer, useTaskMultiSelectStore, } from "./use-task-multi-select"; -import { useTaskActions } from "./use-task-actions"; +import { useTaskActions, useArchiveAndSwitchTask } from "./use-task-actions"; import { useTaskWorkflowMove } from "./use-task-workflow-move"; +import { useToast } from "@/components/toast-provider"; +import { useAppStoreApi } from "@/components/state-provider"; /** * Sidebar task multi-select. Reuses the shared selection reducer (toggle + @@ -28,9 +30,12 @@ export function useSidebarMultiSelect(workspaceId: string | null) { dispatch({ type: "reset" }); }, [workspaceId]); + const store = useAppStoreApi(); const { archiveTaskById } = useTaskActions(); + const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); const { removeTasksFromStore } = useTaskMultiSelectStore(); const moveTasks = useTaskWorkflowMove(); + const { toast } = useToast(); const toggleSelect = useCallback( (taskId: string) => dispatch({ type: "toggle_select", taskId }), @@ -48,17 +53,40 @@ export function useSidebarMultiSelect(workspaceId: string | null) { const bulkArchive = useCallback( async (ids: string[], opts?: { cascade?: boolean }) => { if (ids.length === 0) return; + // If the open task is in the set, archive it via the switch-aware path last + // so the URL/layout moves off it instead of showing stale content. + const activeId = store.getState().tasks.activeTaskId; + const activeInSet = activeId != null && ids.includes(activeId); + const restIds = activeInSet ? ids.filter((id) => id !== activeId) : ids; setIsArchiving(true); try { - const results = await Promise.allSettled(ids.map((id) => archiveTaskById(id, opts))); - const succeeded = new Set(ids.filter((_, i) => results[i].status === "fulfilled")); - removeTasksFromStore(succeeded); + const results = await Promise.allSettled(restIds.map((id) => archiveTaskById(id, opts))); + const failed = restIds.filter((_, i) => results[i].status === "rejected"); + const succeeded = restIds.filter((_, i) => results[i].status === "fulfilled"); + if (succeeded.length > 0) removeTasksFromStore(new Set(succeeded)); + if (activeInSet) { + try { + await archiveAndSwitch(activeId!, opts); + } catch { + failed.push(activeId!); + } + } + if (failed.length > 0) { + // Mirror the kanban hook: keep the failed ids selected so the user can + // retry, and surface the failure instead of silently clearing. + dispatch({ type: "set_selected", ids: new Set(failed) }); + toast({ + title: `Failed to archive ${failed.length} task${failed.length === 1 ? "" : "s"}`, + variant: "error", + }); + return; + } + clearSelection(); } finally { setIsArchiving(false); - clearSelection(); } }, - [archiveTaskById, removeTasksFromStore, clearSelection], + [store, archiveTaskById, archiveAndSwitch, removeTasksFromStore, clearSelection, toast], ); const bulkMove = useCallback( diff --git a/apps/web/hooks/use-task-multi-select-range.test.ts b/apps/web/hooks/use-task-multi-select-range.test.ts index 5f4b30ab9a..9e418d53c7 100644 --- a/apps/web/hooks/use-task-multi-select-range.test.ts +++ b/apps/web/hooks/use-task-multi-select-range.test.ts @@ -50,7 +50,7 @@ describe("multiSelectReducer — anchor + range", () => { expect(next.selectedIds).toEqual(new Set(["x", "a", "b", "c"])); }); - it("falls back to single select when the anchor is in another column", () => { + it("unions with the existing selection and re-anchors when the anchor is in another column", () => { const anchored = { ...INITIAL_STATE, selectedIds: new Set(["z"]), anchorId: "z" }; const next = multiSelectReducer(anchored, { type: "select_range", @@ -61,9 +61,40 @@ describe("multiSelectReducer — anchor + range", () => { expect(next.anchorId).toBe("c"); }); + it("toggling off the only selected task clears the anchor", () => { + const one = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "a" }); + expect(one.anchorId).toBe("a"); + const empty = multiSelectReducer(one, { type: "toggle_select", taskId: "a" }); + expect(empty.selectedIds.size).toBe(0); + expect(empty.anchorId).toBeNull(); + }); + + it("toggling off the anchor realigns it to a remaining selected task", () => { + let s = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "a" }); + s = multiSelectReducer(s, { type: "toggle_select", taskId: "b" }); + // anchor is now 'b'; remove it and the anchor must move to the surviving 'a'. + const next = multiSelectReducer(s, { type: "toggle_select", taskId: "b" }); + expect(next.selectedIds).toEqual(new Set(["a"])); + expect(next.anchorId).toBe("a"); + }); + it("clearing the selection (set_selected empty) drops the anchor", () => { const dirty = { ...INITIAL_STATE, selectedIds: new Set(["a"]), anchorId: "a" }; const next = multiSelectReducer(dirty, { type: "set_selected", ids: new Set() }); expect(next.anchorId).toBeNull(); }); + + it("set_selected keeps the anchor when it survives the replacement", () => { + const state = { ...INITIAL_STATE, selectedIds: new Set(["a", "b", "c"]), anchorId: "b" }; + const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["b", "c"]) }); + expect(next.anchorId).toBe("b"); + }); + + it("set_selected realigns the anchor when it is dropped (partial bulk failure)", () => { + const state = { ...INITIAL_STATE, selectedIds: new Set(["a", "b", "c"]), anchorId: "a" }; + // 'a' archived away; only the failed ids remain — anchor must point at one. + const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["b", "c"]) }); + expect(next.anchorId).not.toBeNull(); + expect(next.selectedIds.has(next.anchorId!)).toBe(true); + }); }); diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index 983c3dbfdd..a8220aff5d 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -196,6 +196,17 @@ export const INITIAL_STATE: MultiSelectState = { anchorId: null, }; +/** + * Pick a valid range anchor after the selection set is replaced wholesale: keep + * the existing anchor if it survived, otherwise fall back to any remaining id + * (or null when the selection is now empty). + */ +function realignAnchor(state: MultiSelectState, ids: Set): string | null { + if (ids.size === 0) return null; + if (state.anchorId && ids.has(state.anchorId)) return state.anchorId; + return ids.values().next().value ?? null; +} + /** * Union-select every id from the anchor to `taskId` (inclusive) within * `orderedIds`. When there is no valid anchor in `orderedIds` (first shift @@ -231,18 +242,26 @@ export function multiSelectReducer( return INITIAL_STATE; case "toggle_select": { const next = new Set(state.selectedIds); - if (next.has(action.taskId)) next.delete(action.taskId); - else next.add(action.taskId); - return { ...state, selectedIds: next, anchorId: action.taskId }; + const added = !next.has(action.taskId); + if (added) next.add(action.taskId); + else next.delete(action.taskId); + // Adding anchors to the toggled task; removing realigns to a surviving id + // (or clears the anchor when the selection is now empty) so a later + // Shift+click can't range from a stale anchor. + return { + ...state, + selectedIds: next, + anchorId: added ? action.taskId : realignAnchor(state, next), + }; } case "select_range": return applyRangeSelect(state, action.taskId, action.orderedIds); case "set_selected": - return { - ...state, - selectedIds: action.ids, - anchorId: action.ids.size ? state.anchorId : null, - }; + // Keep the range anchor pointing at a still-selected task. After a partial + // bulk failure (selection replaced with the failed ids) the old anchor may + // be gone, so realign to a remaining id rather than stranding the next + // Shift+click on an invalid anchor. + return { ...state, selectedIds: action.ids, anchorId: realignAnchor(state, action.ids) }; case "set_enabled": return { ...state, isMultiSelectEnabled: action.value }; case "set_deleting": From cdfbe761f6b8d11632ab0b2e4e3a8820e50d9c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 16:30:59 +0100 Subject: [PATCH 06/19] test: scope multi-select e2e helpers to active containers --- apps/web/e2e/pages/sidebar-tasks-page.ts | 11 ++++++-- .../kanban/cmd-shift-multi-select.spec.ts | 25 +++++++++++++------ .../tests/task/sidebar-multi-select.spec.ts | 3 ++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/e2e/pages/sidebar-tasks-page.ts b/apps/web/e2e/pages/sidebar-tasks-page.ts index 2d68734f55..e896e6788c 100644 --- a/apps/web/e2e/pages/sidebar-tasks-page.ts +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -8,11 +8,18 @@ export class SidebarTasksPage { readonly root: Locator; constructor(private readonly page: Page) { - this.root = page.getByTestId("task-sidebar"); + // Anchor to a single active sidebar — dock/mobile layouts can mount more + // than one `task-sidebar` and a stale one must not satisfy these reads. + this.root = page.getByTestId("task-sidebar").first(); } row(taskId: string): Locator { - return this.root.locator(`[data-testid='sidebar-task-item'][data-task-id='${taskId}']`); + // The per-task id lives on the sortable-task-block wrapper; the selection + // state (data-multiselected) lives on the sidebar-task-item row inside it. + return this.root + .locator(`[data-testid='sortable-task-block'][data-task-id='${taskId}']`) + .getByTestId("sidebar-task-item") + .first(); } rows(): Locator { diff --git a/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts b/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts index 97c191acdc..fece304b7e 100644 --- a/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts +++ b/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts @@ -9,10 +9,15 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; +// Scope DOM reads to the active board so a hidden/stale mounted layout can't +// satisfy these selectors (dock/mobile layouts can coexist in the DOM). +function activeColumn(page: Page, stepId: string) { + return page.getByTestId("kanban-board").getByTestId(`kanban-column-${stepId}`); +} + /** Rendered (display-order) task ids inside a column. */ async function columnTaskIds(page: Page, stepId: string): Promise { - return page - .getByTestId(`kanban-column-${stepId}`) + return activeColumn(page, stepId) .locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])") .evaluateAll((els: Element[]) => els.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")), @@ -21,8 +26,7 @@ async function columnTaskIds(page: Page, stepId: string): Promise { /** Task ids of the selected (primary-ring) cards in a column. */ async function columnSelectedIds(page: Page, stepId: string): Promise { - return page - .getByTestId(`kanban-column-${stepId}`) + return activeColumn(page, stepId) .locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])") .evaluateAll((els: Element[]) => els @@ -134,7 +138,8 @@ test.describe("Kanban cmd/shift multi-select", () => { await expect(kanban.taskCard(b1.id)).toBeVisible({ timeout: 10_000 }); // Anchor in column A, shift-click into column B. The anchor isn't in B's - // column order, so it falls back to selecting just the clicked card. + // column order, so the range falls back to union-with-previous and re-anchors + // to the clicked card — A stays selected, B is added, A's column isn't spanned. await kanban.cmdClickCard(a1.id); await kanban.shiftClickCard(b1.id); @@ -176,15 +181,21 @@ test.describe("Kanban cmd/shift multi-select", () => { apiClient, seedData, }) => { - await apiClient.createTask(seedData.workspaceId, "Toggle Coexist", { + const task = await apiClient.createTask(seedData.workspaceId, "Toggle Coexist", { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, }); const kanban = new KanbanPage(testPage); await kanban.goto(); - await expect(kanban.taskCardByTitle("Toggle Coexist")).toBeVisible({ timeout: 10_000 }); + await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 10_000 }); + // Enter multi-select via the toggle button, then a Cmd-click must still + // select a card (the two entry paths coexist). await kanban.multiSelectToggle.first().click(); await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible(); + + await kanban.cmdClickCard(task.id); + await kanban.expectCardSelected(task.id, true); + await expect(kanban.multiSelectToolbar).toContainText("1 selected"); }); }); diff --git a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts index cfe67868a7..b0ea27e8a5 100644 --- a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts @@ -29,7 +29,8 @@ async function seedTasks( async function sidebarOrder(page: Page): Promise { return page .getByTestId("task-sidebar") - .locator("[data-testid='sidebar-task-item']") + .first() + .locator("[data-testid='sortable-task-block']") .evaluateAll((els: Element[]) => els.map((e) => e.getAttribute("data-task-id") ?? "")); } From 6aa36c8eeafbe62175dcec9ed85d424f0e93a47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 16:58:32 +0100 Subject: [PATCH 07/19] fix: add sidebar multi-select hook tests and tighten bulk-action lifecycle --- .../task/task-session-sidebar-selection.tsx | 1 + .../task/task-switcher-context-menu.tsx | 15 ++- apps/web/components/task/task-switcher.tsx | 9 ++ .../hooks/use-sidebar-multi-select.test.ts | 127 ++++++++++++++++++ apps/web/hooks/use-sidebar-multi-select.ts | 5 +- apps/web/hooks/use-task-multi-select.ts | 5 +- 6 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 apps/web/hooks/use-sidebar-multi-select.test.ts diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 2d6bf4a8a4..01a7e6d350 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -96,6 +96,7 @@ export function useSidebarSelection({ onSelectTaskRange, onBulkArchive: handleBulkArchive, onBulkMove: bulkMove, + onClearSelection: clearSelection, isMixedWorkflowSelection, }; diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 99cd10d28e..b07b75d3bc 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -66,6 +66,7 @@ type ContextMenuProps = { selectedTaskIds?: Set; onBulkArchive?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ isMixedWorkflowSelection?: boolean; }; @@ -88,6 +89,7 @@ export function TaskItemWithContextMenu({ selectedTaskIds, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, }: ContextMenuProps) { const [contextOpen, setContextOpen] = useState(false); @@ -122,6 +124,7 @@ export function TaskItemWithContextMenu({ selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} onBulkMove={onBulkMove} + onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} closeMenu={closeMenu} moveTasks={moveTasks} @@ -153,6 +156,7 @@ function TaskContextMenuItems({ selectedTaskIds, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, closeMenu, moveTasks, @@ -162,6 +166,15 @@ function TaskContextMenuItems({ // a non-selected row acts on just that task and leaves the selection intact. const actingOnSelection = !!selectedTaskIds?.has(task.id); const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id]; + // Delete has no bulk variant here, but deleting a selected row must drop it + // from the selection so later plain clicks navigate instead of toggling. + const onDelete = + actingOnSelection && onClearSelection + ? (id: string) => { + onClearSelection(); + onDeleteTask?.(id); + } + : onDeleteTask; return ( <> - + ); } diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 9244a2a9a0..9a4bb3a722 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -76,6 +76,7 @@ type TaskSwitcherProps = { onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; @@ -192,6 +193,7 @@ type TaskRowProps = { onSelectTaskRange?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; @@ -219,6 +221,7 @@ function TaskRow({ onSelectTaskRange, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, }: TaskRowProps) { const isSelected = task.id === selectedTaskId || task.id === activeTaskId; @@ -243,6 +246,7 @@ function TaskRow({ selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} onBulkMove={onBulkMove} + onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} > void; onBulkArchive?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; isMixedWorkflowSelection?: boolean; }; @@ -450,6 +455,7 @@ function GroupSection({ onSelectTaskRange, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, }: GroupSectionProps) { const totalCount = countGroupTasks(group.tasks, subTasksByParentId); @@ -477,6 +483,7 @@ function GroupSection({ onSelectTaskRange, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, }, onReorderGroup, @@ -530,6 +537,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onSelectTaskRange, onBulkArchive, onBulkMove, + onClearSelection, isMixedWorkflowSelection, }: TaskSwitcherProps) { const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); @@ -577,6 +585,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onSelectTaskRange={onSelectTaskRange} onBulkArchive={onBulkArchive} onBulkMove={onBulkMove} + onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} /> ))} diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts new file mode 100644 index 0000000000..0d42906015 --- /dev/null +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -0,0 +1,127 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const archiveTaskById = vi.fn(); +const archiveAndSwitch = vi.fn(); +const removeTasksFromStore = vi.fn(); +const moveTasks = vi.fn(); +const toast = vi.fn(); +let activeTaskId: string | null = null; + +vi.mock("./use-task-actions", () => ({ + useTaskActions: () => ({ archiveTaskById }), + useArchiveAndSwitchTask: () => archiveAndSwitch, +})); +vi.mock("./use-task-workflow-move", () => ({ useTaskWorkflowMove: () => moveTasks })); +vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast }) })); +vi.mock("@/components/state-provider", () => ({ + useAppStoreApi: () => ({ getState: () => ({ tasks: { activeTaskId } }) }), +})); +vi.mock("./use-task-multi-select", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTaskMultiSelectStore: () => ({ removeTasksFromStore }) }; +}); + +import { useSidebarMultiSelect } from "./use-sidebar-multi-select"; + +beforeEach(() => { + activeTaskId = null; + archiveTaskById.mockReset().mockResolvedValue(undefined); + archiveAndSwitch.mockReset().mockResolvedValue(undefined); + removeTasksFromStore.mockReset(); + moveTasks.mockReset().mockResolvedValue(undefined); + toast.mockReset(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useSidebarMultiSelect", () => { + it("toggles, ranges, and clears the selection", () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + expect(result.current.isSelecting).toBe(false); + + act(() => result.current.toggleSelect("a")); + act(() => result.current.toggleSelect("b")); + expect(result.current.selectedIds).toEqual(new Set(["a", "b"])); + expect(result.current.isSelecting).toBe(true); + + act(() => result.current.clearSelection()); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("resets the selection when the workspace changes", () => { + const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), { + initialProps: { ws: "ws1" }, + }); + act(() => result.current.toggleSelect("a")); + expect(result.current.selectedIds.size).toBe(1); + + rerender({ ws: "ws2" }); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("bulkArchive removes all on full success and clears the selection", async () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkArchive(["a", "b"]); + }); + expect(archiveTaskById).toHaveBeenCalledTimes(2); + expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"])); + expect(result.current.selectedIds.size).toBe(0); + expect(toast).not.toHaveBeenCalled(); + }); + + it("bulkArchive keeps the failed ids selected and toasts on partial failure", async () => { + archiveTaskById.mockImplementation((id) => + id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(), + ); + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkArchive(["a", "b"]); + }); + expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a"])); + expect(result.current.selectedIds).toEqual(new Set(["b"])); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + }); + + it("bulkArchive routes the active task through the switch-aware path", async () => { + activeTaskId = "a"; + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkArchive(["a", "b"]); + }); + expect(archiveAndSwitch).toHaveBeenCalledWith("a", undefined); + expect(archiveTaskById).toHaveBeenCalledTimes(1); + expect(archiveTaskById).toHaveBeenCalledWith("b", undefined); + expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["b"])); + }); + + it("bulkArchive ignores an empty id list", async () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkArchive([]); + }); + expect(archiveTaskById).not.toHaveBeenCalled(); + }); + + it("bulkMove clears the selection on success", async () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + act(() => result.current.toggleSelect("a")); + await act(async () => { + await result.current.bulkMove(["a"], "wf1", "s1"); + }); + expect(moveTasks).toHaveBeenCalledWith(["a"], "wf1", "s1"); + expect(result.current.selectedIds.size).toBe(0); + }); + + it("bulkMove keeps the selection and swallows the rejection on failure", async () => { + moveTasks.mockRejectedValue(new Error("locked")); + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + act(() => result.current.toggleSelect("a")); + await act(async () => { + await result.current.bulkMove(["a"], "wf1", "s1"); + }); + expect(result.current.selectedIds).toEqual(new Set(["a"])); + }); +}); diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index 15d58a7cd6..eede06ae8d 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -94,8 +94,11 @@ export function useSidebarMultiSelect(workspaceId: string | null) { if (ids.length === 0) return; try { await moveTasks(ids, targetWorkflowId, targetStepId); - } finally { clearSelection(); + } catch { + // useTaskWorkflowMove already toasts the failure; keep the rows selected + // for retry and swallow the rejection so it isn't unhandled at the + // fire-and-forget call site. } }, [moveTasks, clearSelection], diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index a8220aff5d..6d2a4602fc 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -210,8 +210,9 @@ function realignAnchor(state: MultiSelectState, ids: Set): string | null /** * Union-select every id from the anchor to `taskId` (inclusive) within * `orderedIds`. When there is no valid anchor in `orderedIds` (first shift - * click, or anchor lives in a different column), fall back to selecting just - * `taskId` and making it the new anchor. + * click, or anchor lives in a different column), fall back to union-selecting + * just `taskId` — the previous selection is preserved — and make it the new + * anchor. */ function applyRangeSelect( state: MultiSelectState, From e6f5044372a08e89f67443245653ddb35b57ac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 17:17:49 +0100 Subject: [PATCH 08/19] fix: prune hidden sidebar selections and order bulk moves by view --- .../task/task-session-sidebar-selection.tsx | 20 ++++++++++++++++++- .../hooks/use-sidebar-multi-select.test.ts | 13 ++++++++++++ apps/web/hooks/use-sidebar-multi-select.ts | 13 ++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 01a7e6d350..60ae31a3f0 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -33,6 +33,7 @@ export function useSidebarSelection({ clearSelection, selectRange, toggleSelect, + pruneToVisible, bulkArchive, bulkMove, } = multiSelect; @@ -83,6 +84,12 @@ export function useSidebarSelection({ return () => window.removeEventListener("keydown", onKey); }, [isSelecting, clearSelection]); + // Prune selections that scroll out of view (collapsed group / filter change) + // so plain clicks on visible rows stop behaving as selection-mode. + useEffect(() => { + pruneToVisible(visibleTaskIds); + }, [visibleTaskIds, pruneToVisible]); + // Stable ref so TaskSwitcher's React.memo isn't defeated by a fresh closure // on every unrelated sidebar re-render. const onSelectTaskRange = useCallback( @@ -90,12 +97,23 @@ export function useSidebarSelection({ [selectRange, visibleTaskIds], ); + // Bulk move in the rendered top-to-bottom order so a backward range selection + // (anchor after target) doesn't land scrambled at the destination. + const onBulkMove = useCallback( + (ids: string[], targetWorkflowId: string, targetStepId: string) => { + const order = new Map(visibleTaskIds.map((id, i) => [id, i])); + const sorted = [...ids].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)); + return bulkMove(sorted, targetWorkflowId, targetStepId); + }, + [bulkMove, visibleTaskIds], + ); + const switcherProps = { selectedTaskIds: selectedIds, onToggleSelectTask: toggleSelect, onSelectTaskRange, onBulkArchive: handleBulkArchive, - onBulkMove: bulkMove, + onBulkMove, onClearSelection: clearSelection, isMixedWorkflowSelection, }; diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts index 0d42906015..95d739dfe8 100644 --- a/apps/web/hooks/use-sidebar-multi-select.test.ts +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -50,6 +50,19 @@ describe("useSidebarMultiSelect", () => { expect(result.current.selectedIds.size).toBe(0); }); + it("pruneToVisible drops selected ids that are no longer visible", () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + act(() => result.current.toggleSelect("a")); + act(() => result.current.toggleSelect("b")); + + act(() => result.current.pruneToVisible(["a"])); + expect(result.current.selectedIds).toEqual(new Set(["a"])); + + // No-op when everything is still visible. + act(() => result.current.pruneToVisible(["a", "b"])); + expect(result.current.selectedIds).toEqual(new Set(["a"])); + }); + it("resets the selection when the workspace changes", () => { const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), { initialProps: { ws: "ws1" }, diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index eede06ae8d..dce8fe83cb 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -50,6 +50,18 @@ export function useSidebarMultiSelect(workspaceId: string | null) { const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []); + // Drop any selected ids that are no longer visible (collapsed group / filtered + // out) so `isSelecting` doesn't stay latched on hidden rows. No-ops (and avoids + // a render loop) when every selected id is still visible. + const pruneToVisible = useCallback( + (visibleIds: string[]) => { + const visible = new Set(visibleIds); + const next = new Set([...selectedIds].filter((id) => visible.has(id))); + if (next.size !== selectedIds.size) dispatch({ type: "set_selected", ids: next }); + }, + [selectedIds], + ); + const bulkArchive = useCallback( async (ids: string[], opts?: { cascade?: boolean }) => { if (ids.length === 0) return; @@ -111,6 +123,7 @@ export function useSidebarMultiSelect(workspaceId: string | null) { toggleSelect, selectRange, clearSelection, + pruneToVisible, bulkArchive, bulkMove, }; From c2a892895d7f6f41abc7bdacb4bf21ff77b2df53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 28 Jun 2026 17:29:32 +0100 Subject: [PATCH 09/19] test: cover visible-order bulk move and anchor realignment --- .../task/task-session-sidebar-selection.tsx | 13 ++++++----- .../hooks/use-sidebar-multi-select.test.ts | 12 ++++++++++ .../lib/sidebar/apply-view-flatten.test.ts | 23 ++++++++++++++++++- apps/web/lib/sidebar/apply-view.ts | 11 +++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 60ae31a3f0..c04e94e26e 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSidebarMultiSelect } from "@/hooks/use-sidebar-multi-select"; -import { flattenVisibleTaskIds, type GroupedSidebarList } from "@/lib/sidebar/apply-view"; +import { + flattenVisibleTaskIds, + sortIdsByVisibleOrder, + type GroupedSidebarList, +} from "@/lib/sidebar/apply-view"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; type BulkArchiveState = { ids: string[]; executorTypes: Array }; @@ -100,11 +104,8 @@ export function useSidebarSelection({ // Bulk move in the rendered top-to-bottom order so a backward range selection // (anchor after target) doesn't land scrambled at the destination. const onBulkMove = useCallback( - (ids: string[], targetWorkflowId: string, targetStepId: string) => { - const order = new Map(visibleTaskIds.map((id, i) => [id, i])); - const sorted = [...ids].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)); - return bulkMove(sorted, targetWorkflowId, targetStepId); - }, + (ids: string[], targetWorkflowId: string, targetStepId: string) => + bulkMove(sortIdsByVisibleOrder(ids, visibleTaskIds), targetWorkflowId, targetStepId), [bulkMove, visibleTaskIds], ); diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts index 95d739dfe8..ef8b340c5f 100644 --- a/apps/web/hooks/use-sidebar-multi-select.test.ts +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -63,6 +63,18 @@ describe("useSidebarMultiSelect", () => { expect(result.current.selectedIds).toEqual(new Set(["a"])); }); + it("pruneToVisible realigns the anchor so a later range starts from a visible id", () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + act(() => result.current.toggleSelect("a")); + act(() => result.current.toggleSelect("b")); // anchor is now "b" + act(() => result.current.pruneToVisible(["a"])); // drops "b", anchor must realign to "a" + + // Range from the realigned anchor "a" to "c" includes the in-between "x". + // A stale "b" anchor (absent from orderedIds) would fall back to just {a, c}. + act(() => result.current.selectRange("c", ["a", "x", "c"])); + expect(result.current.selectedIds).toEqual(new Set(["a", "x", "c"])); + }); + it("resets the selection when the workspace changes", () => { const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), { initialProps: { ws: "ws1" }, diff --git a/apps/web/lib/sidebar/apply-view-flatten.test.ts b/apps/web/lib/sidebar/apply-view-flatten.test.ts index d0293c0d66..3838048f3e 100644 --- a/apps/web/lib/sidebar/apply-view-flatten.test.ts +++ b/apps/web/lib/sidebar/apply-view-flatten.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; -import { flattenVisibleTaskIds, type GroupedSidebarList } from "./apply-view"; +import { + flattenVisibleTaskIds, + sortIdsByVisibleOrder, + type GroupedSidebarList, +} from "./apply-view"; function task(id: string): TaskSwitcherItem { return { id, title: id }; @@ -42,3 +46,20 @@ describe("flattenVisibleTaskIds", () => { expect(flattenVisibleTaskIds(g, [], ["a"])).toEqual(["a"]); }); }); + +describe("sortIdsByVisibleOrder", () => { + const visible = ["a", "b", "c", "d"]; + + it("reorders a backward range selection into visible order", () => { + // Anchor 'd' then shift to 'b' leaves the Set as [d, b, c] (insertion order). + expect(sortIdsByVisibleOrder(["d", "b", "c"], visible)).toEqual(["b", "c", "d"]); + }); + + it("leaves an already-ordered list unchanged", () => { + expect(sortIdsByVisibleOrder(["a", "c"], visible)).toEqual(["a", "c"]); + }); + + it("sorts ids missing from the visible list to the front", () => { + expect(sortIdsByVisibleOrder(["c", "zzz", "a"], visible)).toEqual(["zzz", "a", "c"]); + }); +}); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 52d5e5ead6..2db07c6ed4 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -484,6 +484,17 @@ export function flattenVisibleTaskIds( return out; } +/** + * Order `ids` by their position in the rendered `visibleTaskIds` list. Used + * before bulk moves so a backward range selection (anchor after target, which + * leaves the selection Set in insertion rather than visible order) still lands + * top-to-bottom at the destination. Ids absent from the visible list sort first. + */ +export function sortIdsByVisibleOrder(ids: string[], visibleTaskIds: string[]): string[] { + const order = new Map(visibleTaskIds.map((id, i) => [id, i])); + return [...ids].sort((a, b) => (order.get(a) ?? -1) - (order.get(b) ?? -1)); +} + export function applyView( tasks: TaskSwitcherItem[], view: SidebarView, From b7a0d1abd10856ca789d7d795829700ac3a32e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 29 Jun 2026 13:57:36 +0100 Subject: [PATCH 10/19] fix: harden sidebar bulk-move scope and keyboard selection parity --- apps/web/components/task/task-item.tsx | 23 +++-- .../task/task-session-sidebar-selection.tsx | 96 ++++++++++++------- .../task/task-switcher-context-menu.tsx | 5 +- apps/web/components/task/task-switcher.tsx | 2 +- 4 files changed, 83 insertions(+), 43 deletions(-) diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 7f5cf3b2be..a2dcd88196 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -43,11 +43,11 @@ type TaskItemProps = { isMultiSelected?: boolean; onClick?: () => void; /** - * Modifier-aware mouse handler. When provided, the row delegates click to this - * (cmd/shift/plain dispatch lives in the parent); `onClick` remains the plain - * keyboard-activation path. + * Modifier-aware activation handler. When provided, both mouse clicks and + * keyboard Enter/Space delegate here (cmd/shift/plain dispatch lives in the + * parent); `onClick` is the fallback when no selection handler is wired. */ - onSelect?: (e: React.MouseEvent) => void; + onSelect?: (e: React.MouseEvent | React.KeyboardEvent) => void; diffStats?: DiffStats; isRemoteExecutor?: boolean; remoteExecutorType?: string; @@ -110,10 +110,17 @@ function computeIsPreparing(state?: TaskState, sessionState?: TaskSessionState): return sessionState === "STARTING" && classifyTask(sessionState, state) !== "review"; } -function handleTaskItemKeyDown(e: React.KeyboardEvent, onClick?: () => void): void { +function handleTaskItemKeyDown( + e: React.KeyboardEvent, + onSelect: ((e: React.KeyboardEvent) => void) | undefined, + onClick: (() => void) | undefined, +): void { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); - onClick?.(); + // Keyboard activation mirrors mouse: when a selection-aware handler is wired, + // Enter/Space toggles/extends the selection just like a click would. + if (onSelect) onSelect(e); + else onClick?.(); } function taskItemRowClassName( @@ -135,7 +142,7 @@ function taskItemRowClassName( /** Mouse uses the modifier-aware `onSelect`; falls back to the plain `onClick`. */ function taskItemRowClick( - onSelect: ((e: React.MouseEvent) => void) | undefined, + onSelect: ((e: React.MouseEvent | React.KeyboardEvent) => void) | undefined, onClick: (() => void) | undefined, ): (e: React.MouseEvent) => void { return (e) => (onSelect ? onSelect(e) : onClick?.()); @@ -421,7 +428,7 @@ export const TaskItem = memo(function TaskItem({ data-multiselected={isMultiSelected ? "true" : undefined} aria-current={isSelected ? "true" : undefined} onClick={taskItemRowClick(onSelect, onClick)} - onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)} + onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)} style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined} className={taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0)} > diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index c04e94e26e..924f57df46 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -11,6 +11,38 @@ import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm type BulkArchiveState = { ids: string[]; executorTypes: Array }; +/** Owns the bulk-archive confirm dialog state + the archive call. */ +function useBulkArchiveDialog( + displayTasks: Array<{ id: string; remoteExecutorType?: string | null }>, + bulkArchive: (ids: string[], opts?: { cascade?: boolean }) => Promise, +) { + const [state, setState] = useState(null); + + const open = useCallback( + (ids: string[]) => { + const byId = new Map(displayTasks.map((t) => [t.id, t.remoteExecutorType])); + setState({ ids, executorTypes: ids.map((id) => byId.get(id) ?? null) }); + }, + [displayTasks], + ); + + const confirm = useCallback( + async ({ cascade }: { cascade: boolean }) => { + if (!state) return; + try { + await bulkArchive(state.ids, { cascade }); + } catch (error) { + console.error("Failed to archive tasks:", error); + } finally { + setState(null); + } + }, + [state, bulkArchive], + ); + + return { state, setState, open, confirm }; +} + /** * Sidebar multi-select wiring: selection state, the visible-order list shift * range-select walks, mixed-workflow detection (gates bulk "Move to step"), and @@ -41,43 +73,35 @@ export function useSidebarSelection({ bulkArchive, bulkMove, } = multiSelect; - const [bulkArchiveState, setBulkArchiveState] = useState(null); + const archiveDialog = useBulkArchiveDialog(displayTasks, bulkArchive); const visibleTaskIds = useMemo( () => flattenVisibleTaskIds(grouped, collapsedGroups, collapsedSubtaskParents), [grouped, collapsedGroups, collapsedSubtaskParents], ); - const isMixedWorkflowSelection = useMemo(() => { + // A workflow-less selected row (e.g. the archived placeholder) can't be moved, + // so treat its presence as a mixed selection (disables "Move to step") and + // filter such ids out of the actual move below. + const { isMixedWorkflowSelection, movableSelectedIds } = useMemo(() => { const wfIds = new Set(); + const movable = new Set(); + let hasWorkflowless = false; for (const t of displayTasks) { - if (selectedIds.has(t.id) && t.workflowId) wfIds.add(t.workflowId); + if (!selectedIds.has(t.id)) continue; + if (t.workflowId) { + wfIds.add(t.workflowId); + movable.add(t.id); + } else { + hasWorkflowless = true; + } } - return wfIds.size > 1; + return { + isMixedWorkflowSelection: hasWorkflowless || wfIds.size > 1, + movableSelectedIds: movable, + }; }, [displayTasks, selectedIds]); - const handleBulkArchive = useCallback( - (ids: string[]) => { - const byId = new Map(displayTasks.map((t) => [t.id, t.remoteExecutorType])); - setBulkArchiveState({ ids, executorTypes: ids.map((id) => byId.get(id) ?? null) }); - }, - [displayTasks], - ); - - const handleBulkArchiveConfirm = useCallback( - async ({ cascade }: { cascade: boolean }) => { - if (!bulkArchiveState) return; - try { - await bulkArchive(bulkArchiveState.ids, { cascade }); - } catch (error) { - console.error("Failed to archive tasks:", error); - } finally { - setBulkArchiveState(null); - } - }, - [bulkArchiveState, bulkArchive], - ); - // Escape clears an active selection. useEffect(() => { if (!isSelecting) return; @@ -104,16 +128,22 @@ export function useSidebarSelection({ // Bulk move in the rendered top-to-bottom order so a backward range selection // (anchor after target) doesn't land scrambled at the destination. const onBulkMove = useCallback( - (ids: string[], targetWorkflowId: string, targetStepId: string) => - bulkMove(sortIdsByVisibleOrder(ids, visibleTaskIds), targetWorkflowId, targetStepId), - [bulkMove, visibleTaskIds], + (ids: string[], targetWorkflowId: string, targetStepId: string) => { + const movable = ids.filter((id) => movableSelectedIds.has(id)); + return bulkMove( + sortIdsByVisibleOrder(movable, visibleTaskIds), + targetWorkflowId, + targetStepId, + ); + }, + [bulkMove, visibleTaskIds, movableSelectedIds], ); const switcherProps = { selectedTaskIds: selectedIds, onToggleSelectTask: toggleSelect, onSelectTaskRange, - onBulkArchive: handleBulkArchive, + onBulkArchive: archiveDialog.open, onBulkMove, onClearSelection: clearSelection, isMixedWorkflowSelection, @@ -121,9 +151,9 @@ export function useSidebarSelection({ return { switcherProps, - bulkArchiveState, - setBulkArchiveState, - handleBulkArchiveConfirm, + bulkArchiveState: archiveDialog.state, + setBulkArchiveState: archiveDialog.setState, + handleBulkArchiveConfirm: archiveDialog.confirm, isArchiving: multiSelect.isArchiving, }; } diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index b07b75d3bc..2f4e472a19 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -362,7 +362,10 @@ function TaskMoveItems({ return ( Date: Mon, 29 Jun 2026 14:03:42 +0100 Subject: [PATCH 11/19] fix: order kanban bulk moves by board position --- apps/web/hooks/use-task-multi-select.ts | 42 ++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index 6d2a4602fc..a4aadef817 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, type RefOb import { useTaskActions } from "@/hooks/use-task-actions"; import { useAppStoreApi } from "@/components/state-provider"; import type { KanbanState } from "@/lib/state/slices"; +import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; /** @internal Exported for reuse by the sidebar multi-select hook. */ export function useTaskMultiSelectStore() { @@ -78,7 +79,28 @@ export function useTaskMultiSelectStore() { [store], ); - return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask }; + // Sort ids into the board's visible (created-desc) order. A backward range + // selection leaves `selectedIds` in anchor-first Set order, which would land + // scrambled when the move assigns sequential positions. + const sortByDisplayOrder = useCallback( + (ids: string[]): string[] => { + const state = store.getState(); + const taskById = new Map(); + for (const snap of Object.values(state.kanbanMulti.snapshots)) { + for (const t of snap.tasks) taskById.set(t.id, t); + } + for (const t of state.kanban.tasks) if (!taskById.has(t.id)) taskById.set(t.id, t); + return [...ids].sort((a, b) => { + const ta = taskById.get(a); + const tb = taskById.get(b); + if (!ta || !tb) return 0; + return compareTasksByCreatedDesc(ta, tb); + }); + }, + [store], + ); + + return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder }; } function useBulkOperations({ @@ -94,6 +116,7 @@ function useBulkOperations({ removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, + sortByDisplayOrder, }: { workflowId: string | null; selectedIdsRef: RefObject>; @@ -107,6 +130,7 @@ function useBulkOperations({ removeTasksFromStore: (ids: Set) => void; applyMoveInStore: (ids: Set, stepId: string) => void; getWorkflowIdForTask: (id: string) => string | null; + sortByDisplayOrder: (ids: string[]) => string[]; }) { const runBulk = useCallback( async ( @@ -144,7 +168,9 @@ function useBulkOperations({ const bulkMove = useCallback( async (targetStepId: string) => { - const idList = [...(selectedIdsRef.current ?? [])]; + // Move in board order so a backward range selection isn't reordered when + // sequential positions are assigned below. + const idList = sortByDisplayOrder([...(selectedIdsRef.current ?? [])]); if (idList.length === 0) return; const results = await Promise.allSettled( idList.map((id, i) => { @@ -160,7 +186,14 @@ function useBulkOperations({ const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled")); applyMoveInStore(succeeded, targetStepId); }, - [workflowId, moveTaskById, applyMoveInStore, getWorkflowIdForTask, selectedIdsRef], + [ + workflowId, + moveTaskById, + applyMoveInStore, + getWorkflowIdForTask, + sortByDisplayOrder, + selectedIdsRef, + ], ); return { bulkDelete, bulkArchive, bulkMove }; @@ -303,7 +336,7 @@ export function useTaskMultiSelect(workflowId: string | null) { }, [workflowId]); const { moveTaskById, deleteTaskById, archiveTaskById } = useTaskActions(); - const { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask } = + const { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder } = useTaskMultiSelectStore(); const toggleSelect = useCallback( @@ -349,6 +382,7 @@ export function useTaskMultiSelect(workflowId: string | null) { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, + sortByDisplayOrder, }); return { From 5d74b8d7f50b1fe7c58b56ccdccd77210a273b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 29 Jun 2026 14:17:29 +0100 Subject: [PATCH 12/19] test: cover multi-select helpers and fix stale-id sort fallback --- .../task-session-sidebar-selection.test.ts | 54 +++++++++++++++++++ .../task/task-session-sidebar-selection.tsx | 30 ++++------- .../task/task-switcher-click.test.ts | 38 +++++++++++++ apps/web/hooks/use-task-multi-select.ts | 9 +--- apps/web/lib/kanban/task-order.test.ts | 21 +++++++- apps/web/lib/kanban/task-order.ts | 15 ++++++ .../lib/sidebar/apply-view-flatten.test.ts | 32 ++++++++++- apps/web/lib/sidebar/apply-view.ts | 35 +++++++++++- 8 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 apps/web/components/task/task-session-sidebar-selection.test.ts diff --git a/apps/web/components/task/task-session-sidebar-selection.test.ts b/apps/web/components/task/task-session-sidebar-selection.test.ts new file mode 100644 index 0000000000..bb056c7e1a --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-selection.test.ts @@ -0,0 +1,54 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useBulkArchiveDialog } from "./task-session-sidebar-selection"; + +const tasks = [ + { id: "a", remoteExecutorType: "docker" }, + { id: "b", remoteExecutorType: null }, + { id: "c", remoteExecutorType: "sprites" }, +]; + +describe("useBulkArchiveDialog", () => { + it("open() captures the ids and their executor types", () => { + const bulkArchive = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + + act(() => result.current.open(["a", "c"])); + expect(result.current.state).toEqual({ ids: ["a", "c"], executorTypes: ["docker", "sprites"] }); + }); + + it("confirm() archives the captured ids and clears the dialog", async () => { + const bulkArchive = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + + act(() => result.current.open(["a", "b"])); + await act(async () => { + await result.current.confirm({ cascade: true }); + }); + expect(bulkArchive).toHaveBeenCalledWith(["a", "b"], { cascade: true }); + expect(result.current.state).toBeNull(); + }); + + it("confirm() is a no-op when no dialog is open", async () => { + const bulkArchive = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + + await act(async () => { + await result.current.confirm({ cascade: false }); + }); + expect(bulkArchive).not.toHaveBeenCalled(); + }); + + it("clears the dialog even when archiving rejects", async () => { + const bulkArchive = vi.fn().mockRejectedValue(new Error("boom")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + + act(() => result.current.open(["a"])); + await act(async () => { + await result.current.confirm({ cascade: false }); + }); + expect(result.current.state).toBeNull(); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 924f57df46..cbc486277a 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSidebarMultiSelect } from "@/hooks/use-sidebar-multi-select"; import { + computeMixedWorkflowSelection, flattenVisibleTaskIds, sortIdsByVisibleOrder, type GroupedSidebarList, @@ -11,8 +12,11 @@ import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm type BulkArchiveState = { ids: string[]; executorTypes: Array }; -/** Owns the bulk-archive confirm dialog state + the archive call. */ -function useBulkArchiveDialog( +/** + * Owns the bulk-archive confirm dialog state + the archive call. + * @internal Exported for unit testing. + */ +export function useBulkArchiveDialog( displayTasks: Array<{ id: string; remoteExecutorType?: string | null }>, bulkArchive: (ids: string[], opts?: { cascade?: boolean }) => Promise, ) { @@ -83,24 +87,10 @@ export function useSidebarSelection({ // A workflow-less selected row (e.g. the archived placeholder) can't be moved, // so treat its presence as a mixed selection (disables "Move to step") and // filter such ids out of the actual move below. - const { isMixedWorkflowSelection, movableSelectedIds } = useMemo(() => { - const wfIds = new Set(); - const movable = new Set(); - let hasWorkflowless = false; - for (const t of displayTasks) { - if (!selectedIds.has(t.id)) continue; - if (t.workflowId) { - wfIds.add(t.workflowId); - movable.add(t.id); - } else { - hasWorkflowless = true; - } - } - return { - isMixedWorkflowSelection: hasWorkflowless || wfIds.size > 1, - movableSelectedIds: movable, - }; - }, [displayTasks, selectedIds]); + const { isMixedWorkflowSelection, movableSelectedIds } = useMemo( + () => computeMixedWorkflowSelection(displayTasks, selectedIds), + [displayTasks, selectedIds], + ); // Escape clears an active selection. useEffect(() => { diff --git a/apps/web/components/task/task-switcher-click.test.ts b/apps/web/components/task/task-switcher-click.test.ts index 92179a4162..420c41f544 100644 --- a/apps/web/components/task/task-switcher-click.test.ts +++ b/apps/web/components/task/task-switcher-click.test.ts @@ -11,6 +11,16 @@ function fakeEvent(mods: Partial = {}) { } as unknown as React.MouseEvent; } +function fakeKeyEvent(mods: Partial = {}) { + return { + metaKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...mods, + } as unknown as React.KeyboardEvent; +} + function handlers() { return { onSelectTask: vi.fn(), @@ -50,4 +60,32 @@ describe("dispatchSidebarRowClick", () => { expect(h.onSelectTask).toHaveBeenCalledWith("t1"); expect(h.onToggleSelectTask).not.toHaveBeenCalled(); }); + + // Keyboard activation (Enter/Space) routes through the same dispatcher. + it("cmd/meta keypress toggles", () => { + const h = handlers(); + const e = fakeKeyEvent({ metaKey: true }); + dispatchSidebarRowClick(e, "t1", false, h); + expect(e.preventDefault).toHaveBeenCalled(); + expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1"); + }); + + it("shift keypress range-selects", () => { + const h = handlers(); + dispatchSidebarRowClick(fakeKeyEvent({ shiftKey: true }), "t1", false, h); + expect(h.onSelectTaskRange).toHaveBeenCalledWith("t1"); + }); + + it("plain keypress toggles while a selection is active", () => { + const h = handlers(); + dispatchSidebarRowClick(fakeKeyEvent(), "t1", true, h); + expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1"); + expect(h.onSelectTask).not.toHaveBeenCalled(); + }); + + it("plain keypress navigates when nothing is selected", () => { + const h = handlers(); + dispatchSidebarRowClick(fakeKeyEvent(), "t1", false, h); + expect(h.onSelectTask).toHaveBeenCalledWith("t1"); + }); }); diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index a4aadef817..b23ed47c6b 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, type RefOb import { useTaskActions } from "@/hooks/use-task-actions"; import { useAppStoreApi } from "@/components/state-provider"; import type { KanbanState } from "@/lib/state/slices"; -import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; +import { sortIdsByCreatedDesc } from "@/lib/kanban/task-order"; /** @internal Exported for reuse by the sidebar multi-select hook. */ export function useTaskMultiSelectStore() { @@ -90,12 +90,7 @@ export function useTaskMultiSelectStore() { for (const t of snap.tasks) taskById.set(t.id, t); } for (const t of state.kanban.tasks) if (!taskById.has(t.id)) taskById.set(t.id, t); - return [...ids].sort((a, b) => { - const ta = taskById.get(a); - const tb = taskById.get(b); - if (!ta || !tb) return 0; - return compareTasksByCreatedDesc(ta, tb); - }); + return sortIdsByCreatedDesc(ids, taskById); }, [store], ); diff --git a/apps/web/lib/kanban/task-order.test.ts b/apps/web/lib/kanban/task-order.test.ts index d3c2908aa7..221d5f5353 100644 --- a/apps/web/lib/kanban/task-order.test.ts +++ b/apps/web/lib/kanban/task-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compareTasksByCreatedDesc } from "./task-order"; +import { compareTasksByCreatedDesc, sortIdsByCreatedDesc } from "./task-order"; describe("compareTasksByCreatedDesc", () => { it("sorts newer created tasks first", () => { @@ -53,3 +53,22 @@ describe("compareTasksByCreatedDesc", () => { expect(compareTasksByCreatedDesc({}, {})).toBe(0); }); }); + +describe("sortIdsByCreatedDesc", () => { + // d newest … a oldest → board order is d, c, b, a. + const taskById = new Map([ + ["a", { createdAt: "2026-01-01T00:00:00Z" }], + ["b", { createdAt: "2026-01-02T00:00:00Z" }], + ["c", { createdAt: "2026-01-03T00:00:00Z" }], + ["d", { createdAt: "2026-01-04T00:00:00Z" }], + ]); + + it("reorders a backward range selection into board (created-desc) order", () => { + // Anchor on the oldest then shift up leaves the Set as [a, c, b] (insertion). + expect(sortIdsByCreatedDesc(["a", "c", "b"], taskById)).toEqual(["c", "b", "a"]); + }); + + it("keeps relative order for ids without a known task", () => { + expect(sortIdsByCreatedDesc(["zzz", "a"], taskById)).toEqual(["zzz", "a"]); + }); +}); diff --git a/apps/web/lib/kanban/task-order.ts b/apps/web/lib/kanban/task-order.ts index fda5475a42..737388ca9b 100644 --- a/apps/web/lib/kanban/task-order.ts +++ b/apps/web/lib/kanban/task-order.ts @@ -15,3 +15,18 @@ export function compareTasksByCreatedDesc(a: CreatedTask, b: CreatedTask): numbe if (bTime < aTime) return -1; return 0; } + +/** + * Sort `ids` into the board's visible created-desc order using `taskById` for + * lookups. Ids without a known task keep their relative order. Used before a + * kanban bulk move so a backward range selection doesn't land scrambled when + * sequential positions are assigned. + */ +export function sortIdsByCreatedDesc(ids: string[], taskById: Map): string[] { + return [...ids].sort((a, b) => { + const ta = taskById.get(a); + const tb = taskById.get(b); + if (!ta || !tb) return 0; + return compareTasksByCreatedDesc(ta, tb); + }); +} diff --git a/apps/web/lib/sidebar/apply-view-flatten.test.ts b/apps/web/lib/sidebar/apply-view-flatten.test.ts index 3838048f3e..910408d925 100644 --- a/apps/web/lib/sidebar/apply-view-flatten.test.ts +++ b/apps/web/lib/sidebar/apply-view-flatten.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; import { + computeMixedWorkflowSelection, flattenVisibleTaskIds, sortIdsByVisibleOrder, type GroupedSidebarList, @@ -59,7 +60,34 @@ describe("sortIdsByVisibleOrder", () => { expect(sortIdsByVisibleOrder(["a", "c"], visible)).toEqual(["a", "c"]); }); - it("sorts ids missing from the visible list to the front", () => { - expect(sortIdsByVisibleOrder(["c", "zzz", "a"], visible)).toEqual(["zzz", "a", "c"]); + it("sorts ids missing from the visible list to the back", () => { + expect(sortIdsByVisibleOrder(["c", "zzz", "a"], visible)).toEqual(["a", "c", "zzz"]); + }); +}); + +describe("computeMixedWorkflowSelection", () => { + const tasks = [ + { id: "a", workflowId: "wf1" }, + { id: "b", workflowId: "wf1" }, + { id: "c", workflowId: "wf2" }, + { id: "arch" }, // archived placeholder — no workflow + ]; + + it("treats a single-workflow selection as not mixed and all movable", () => { + const r = computeMixedWorkflowSelection(tasks, new Set(["a", "b"])); + expect(r.isMixedWorkflowSelection).toBe(false); + expect(r.movableSelectedIds).toEqual(new Set(["a", "b"])); + }); + + it("flags a selection spanning two workflows as mixed", () => { + const r = computeMixedWorkflowSelection(tasks, new Set(["a", "c"])); + expect(r.isMixedWorkflowSelection).toBe(true); + expect(r.movableSelectedIds).toEqual(new Set(["a", "c"])); + }); + + it("flags a workflow-less selected row as mixed and excludes it from movable", () => { + const r = computeMixedWorkflowSelection(tasks, new Set(["a", "arch"])); + expect(r.isMixedWorkflowSelection).toBe(true); + expect(r.movableSelectedIds).toEqual(new Set(["a"])); }); }); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 2db07c6ed4..61430e09a9 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -488,11 +488,42 @@ export function flattenVisibleTaskIds( * Order `ids` by their position in the rendered `visibleTaskIds` list. Used * before bulk moves so a backward range selection (anchor after target, which * leaves the selection Set in insertion rather than visible order) still lands - * top-to-bottom at the destination. Ids absent from the visible list sort first. + * top-to-bottom at the destination. Ids absent from the visible list sort last + * so they never displace the visible-ordered ones. */ export function sortIdsByVisibleOrder(ids: string[], visibleTaskIds: string[]): string[] { const order = new Map(visibleTaskIds.map((id, i) => [id, i])); - return [...ids].sort((a, b) => (order.get(a) ?? -1) - (order.get(b) ?? -1)); + return [...ids].sort( + (a, b) => + (order.get(a) ?? Number.POSITIVE_INFINITY) - (order.get(b) ?? Number.POSITIVE_INFINITY), + ); +} + +/** + * Decide whether a selection can be bulk-moved to a single step. A selected row + * with no workflow (e.g. the archived placeholder) can't move, so flag the + * selection as "mixed" (disables "Move to step") and report the movable subset. + */ +export function computeMixedWorkflowSelection( + displayTasks: Array<{ id: string; workflowId?: string }>, + selectedIds: Set, +): { isMixedWorkflowSelection: boolean; movableSelectedIds: Set } { + const wfIds = new Set(); + const movable = new Set(); + let hasWorkflowless = false; + for (const t of displayTasks) { + if (!selectedIds.has(t.id)) continue; + if (t.workflowId) { + wfIds.add(t.workflowId); + movable.add(t.id); + } else { + hasWorkflowless = true; + } + } + return { + isMixedWorkflowSelection: hasWorkflowless || wfIds.size > 1, + movableSelectedIds: movable, + }; } export function applyView( From fc8b36296ef899840f1b66c48f07e39322938d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 29 Jun 2026 14:48:20 +0100 Subject: [PATCH 13/19] fix: order kanban card context-menu moves by board position --- apps/web/components/kanban-card.tsx | 8 +++++++- .../components/task/task-session-sidebar-selection.tsx | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 0058cdb41a..83d2ddfc94 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -14,6 +14,7 @@ import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-d import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select"; import { repositorySlug } from "@/lib/repository-slug"; import { formatUserHomePath } from "@/lib/utils"; import { repositoryId as toRepositoryId, type Repository, type TaskState } from "@/lib/types/http"; @@ -113,6 +114,7 @@ function useKanbanCardMenus({ >) { const moveTargets = useKanbanCardMoveTargets(task.id, steps); const moveTasks = useTaskWorkflowMove(); + const { sortByDisplayOrder } = useTaskMultiSelectStore(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); const [showPRDialog, setShowPRDialog] = useState(false); @@ -135,7 +137,11 @@ function useKanbanCardMenus({ } }; - const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id]; + // Sort into board order so a backward range selection isn't scrambled when the + // bulk move assigns sequential positions in request order. + const selectedTaskIds = sortByDisplayOrder( + isSelected && selectedIds?.size ? [...selectedIds] : [task.id], + ); const moveSelectedToStep = (stepId: string) => { if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) { onMove(task, stepId); diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index cbc486277a..89bf626a8a 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -34,6 +34,9 @@ export function useBulkArchiveDialog( async ({ cascade }: { cascade: boolean }) => { if (!state) return; try { + // `bulkArchive` already surfaces failures itself (it keeps failed ids + // selected and toasts), so this catch is defensive only; `finally` + // guarantees the dialog closes regardless. await bulkArchive(state.ids, { cascade }); } catch (error) { console.error("Failed to archive tasks:", error); From f93882577cc1fd21d01238a8e1d0c8ce9c51c7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 1 Jul 2026 13:32:04 +0100 Subject: [PATCH 14/19] feat: sidebar multi-select shows only bulk-valid actions with bulk pin and delete --- .../task-session-sidebar-selection.test.ts | 12 +- .../task/task-session-sidebar-selection.tsx | 200 +++++++++++------- .../components/task/task-session-sidebar.tsx | 4 +- .../task/task-switcher-context-menu.tsx | 105 ++++++++- apps/web/components/task/task-switcher.tsx | 18 ++ apps/web/e2e/pages/sidebar-tasks-page.ts | 16 ++ .../tests/task/sidebar-multi-select.spec.ts | 75 +++++++ .../hooks/use-sidebar-multi-select.test.ts | 51 ++++- apps/web/hooks/use-sidebar-multi-select.ts | 173 +++++++++------ .../slices/ui/sidebar-task-prefs-actions.ts | 18 ++ apps/web/lib/state/slices/ui/types.ts | 2 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 14 ++ apps/web/lib/state/store.ts | 1 + 13 files changed, 541 insertions(+), 148 deletions(-) diff --git a/apps/web/components/task/task-session-sidebar-selection.test.ts b/apps/web/components/task/task-session-sidebar-selection.test.ts index bb056c7e1a..1e23fdb265 100644 --- a/apps/web/components/task/task-session-sidebar-selection.test.ts +++ b/apps/web/components/task/task-session-sidebar-selection.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { useBulkArchiveDialog } from "./task-session-sidebar-selection"; +import { useBulkConfirmDialog } from "./task-session-sidebar-selection"; const tasks = [ { id: "a", remoteExecutorType: "docker" }, @@ -8,10 +8,10 @@ const tasks = [ { id: "c", remoteExecutorType: "sprites" }, ]; -describe("useBulkArchiveDialog", () => { +describe("useBulkConfirmDialog", () => { it("open() captures the ids and their executor types", () => { const bulkArchive = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); act(() => result.current.open(["a", "c"])); expect(result.current.state).toEqual({ ids: ["a", "c"], executorTypes: ["docker", "sprites"] }); @@ -19,7 +19,7 @@ describe("useBulkArchiveDialog", () => { it("confirm() archives the captured ids and clears the dialog", async () => { const bulkArchive = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); act(() => result.current.open(["a", "b"])); await act(async () => { @@ -31,7 +31,7 @@ describe("useBulkArchiveDialog", () => { it("confirm() is a no-op when no dialog is open", async () => { const bulkArchive = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); await act(async () => { await result.current.confirm({ cascade: false }); @@ -42,7 +42,7 @@ describe("useBulkArchiveDialog", () => { it("clears the dialog even when archiving rejects", async () => { const bulkArchive = vi.fn().mockRejectedValue(new Error("boom")); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { result } = renderHook(() => useBulkArchiveDialog(tasks, bulkArchive)); + const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); act(() => result.current.open(["a"])); await act(async () => { diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 89bf626a8a..95d0077f8a 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; import { useSidebarMultiSelect } from "@/hooks/use-sidebar-multi-select"; import { computeMixedWorkflowSelection, @@ -9,18 +10,21 @@ import { type GroupedSidebarList, } from "@/lib/sidebar/apply-view"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; -type BulkArchiveState = { ids: string[]; executorTypes: Array }; +type BulkConfirmState = { ids: string[]; executorTypes: Array }; /** - * Owns the bulk-archive confirm dialog state + the archive call. + * Owns a bulk confirm-dialog's state and runs `run` (archive or delete) on + * confirm. `run` already surfaces its own failures, so the catch here is + * defensive; `finally` guarantees the dialog closes. * @internal Exported for unit testing. */ -export function useBulkArchiveDialog( +export function useBulkConfirmDialog( displayTasks: Array<{ id: string; remoteExecutorType?: string | null }>, - bulkArchive: (ids: string[], opts?: { cascade?: boolean }) => Promise, + run: (ids: string[], opts?: { cascade?: boolean }) => Promise, ) { - const [state, setState] = useState(null); + const [state, setState] = useState(null); const open = useCallback( (ids: string[]) => { @@ -34,27 +38,87 @@ export function useBulkArchiveDialog( async ({ cascade }: { cascade: boolean }) => { if (!state) return; try { - // `bulkArchive` already surfaces failures itself (it keeps failed ids - // selected and toasts), so this catch is defensive only; `finally` - // guarantees the dialog closes regardless. - await bulkArchive(state.ids, { cascade }); + await run(state.ids, { cascade }); } catch (error) { - console.error("Failed to archive tasks:", error); + console.error("Bulk action failed:", error); } finally { setState(null); } }, - [state, bulkArchive], + [state, run], ); return { state, setState, open, confirm }; } +type Multi = ReturnType; + +/** + * The selection-lifecycle effects (Escape-to-clear, prune-hidden) and the + * memoized range/move/pin callbacks threaded into `TaskSwitcher`. + */ +function useSelectionHandlers(args: { + multiSelect: Multi; + pinTasks: (ids: string[]) => void; + visibleTaskIds: string[]; + movableSelectedIds: Set; +}) { + const { multiSelect, pinTasks, visibleTaskIds, movableSelectedIds } = args; + const { isSelecting, clearSelection, selectRange, pruneToVisible } = multiSelect; + + // Escape clears an active selection. + useEffect(() => { + if (!isSelecting) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") clearSelection(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isSelecting, clearSelection]); + + // Prune selections that scroll out of view (collapsed group / filter change) + // so plain clicks on visible rows stop behaving as selection-mode. + useEffect(() => { + pruneToVisible(visibleTaskIds); + }, [visibleTaskIds, pruneToVisible]); + + // Stable ref so TaskSwitcher's React.memo isn't defeated by a fresh closure. + const onSelectTaskRange = useCallback( + (taskId: string) => selectRange(taskId, visibleTaskIds), + [selectRange, visibleTaskIds], + ); + + // Bulk move in rendered order so a backward range selection isn't scrambled at + // the destination; drop workflow-less rows that can't be moved. + const onBulkMove = useCallback( + (ids: string[], targetWorkflowId: string, targetStepId: string) => + multiSelect.bulkMove( + sortIdsByVisibleOrder( + ids.filter((id) => movableSelectedIds.has(id)), + visibleTaskIds, + ), + targetWorkflowId, + targetStepId, + ), + [multiSelect, visibleTaskIds, movableSelectedIds], + ); + + const onBulkPin = useCallback( + (ids: string[]) => { + pinTasks(ids); + clearSelection(); + }, + [pinTasks, clearSelection], + ); + + return { onSelectTaskRange, onBulkMove, onBulkPin }; +} + /** * Sidebar multi-select wiring: selection state, the visible-order list shift * range-select walks, mixed-workflow detection (gates bulk "Move to step"), and - * the bulk-archive confirm dialog. Returns the props bundle for `TaskSwitcher` - * plus the dialog state the panel renders via `SidebarBulkArchiveDialog`. + * the bulk archive/delete confirm dialogs. Returns the props bundle for + * `TaskSwitcher` plus the dialog state the panel renders via `SidebarBulkDialogs`. */ export function useSidebarSelection({ workspaceId, @@ -70,17 +134,10 @@ export function useSidebarSelection({ displayTasks: Array<{ id: string; workflowId?: string; remoteExecutorType?: string | null }>; }) { const multiSelect = useSidebarMultiSelect(workspaceId); - const { - selectedIds, - isSelecting, - clearSelection, - selectRange, - toggleSelect, - pruneToVisible, - bulkArchive, - bulkMove, - } = multiSelect; - const archiveDialog = useBulkArchiveDialog(displayTasks, bulkArchive); + const { selectedIds, clearSelection, toggleSelect } = multiSelect; + const pinTasks = useAppStore((s) => s.pinTasks); + const archiveDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkArchive); + const deleteDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkDelete); const visibleTaskIds = useMemo( () => flattenVisibleTaskIds(grouped, collapsedGroups, collapsedSubtaskParents), @@ -89,54 +146,26 @@ export function useSidebarSelection({ // A workflow-less selected row (e.g. the archived placeholder) can't be moved, // so treat its presence as a mixed selection (disables "Move to step") and - // filter such ids out of the actual move below. + // filter such ids out of the actual move. const { isMixedWorkflowSelection, movableSelectedIds } = useMemo( () => computeMixedWorkflowSelection(displayTasks, selectedIds), [displayTasks, selectedIds], ); - // Escape clears an active selection. - useEffect(() => { - if (!isSelecting) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") clearSelection(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [isSelecting, clearSelection]); - - // Prune selections that scroll out of view (collapsed group / filter change) - // so plain clicks on visible rows stop behaving as selection-mode. - useEffect(() => { - pruneToVisible(visibleTaskIds); - }, [visibleTaskIds, pruneToVisible]); - - // Stable ref so TaskSwitcher's React.memo isn't defeated by a fresh closure - // on every unrelated sidebar re-render. - const onSelectTaskRange = useCallback( - (taskId: string) => selectRange(taskId, visibleTaskIds), - [selectRange, visibleTaskIds], - ); - - // Bulk move in the rendered top-to-bottom order so a backward range selection - // (anchor after target) doesn't land scrambled at the destination. - const onBulkMove = useCallback( - (ids: string[], targetWorkflowId: string, targetStepId: string) => { - const movable = ids.filter((id) => movableSelectedIds.has(id)); - return bulkMove( - sortIdsByVisibleOrder(movable, visibleTaskIds), - targetWorkflowId, - targetStepId, - ); - }, - [bulkMove, visibleTaskIds, movableSelectedIds], - ); + const { onSelectTaskRange, onBulkMove, onBulkPin } = useSelectionHandlers({ + multiSelect, + pinTasks, + visibleTaskIds, + movableSelectedIds, + }); const switcherProps = { selectedTaskIds: selectedIds, onToggleSelectTask: toggleSelect, onSelectTaskRange, onBulkArchive: archiveDialog.open, + onBulkDelete: deleteDialog.open, + onBulkPin, onBulkMove, onClearSelection: clearSelection, isMixedWorkflowSelection, @@ -144,30 +173,47 @@ export function useSidebarSelection({ return { switcherProps, - bulkArchiveState: archiveDialog.state, - setBulkArchiveState: archiveDialog.setState, - handleBulkArchiveConfirm: archiveDialog.confirm, + archiveDialog, + deleteDialog, isArchiving: multiSelect.isArchiving, + isDeleting: multiSelect.isDeleting, }; } -export function SidebarBulkArchiveDialog({ +export function SidebarBulkDialogs({ selection, }: { selection: ReturnType; }) { - if (!selection.bulkArchiveState) return null; + const { archiveDialog, deleteDialog } = selection; return ( - !o && selection.setBulkArchiveState(null)} - isBulkOperation - count={selection.bulkArchiveState.ids.length} - taskIds={selection.bulkArchiveState.ids} - executorTypes={selection.bulkArchiveState.executorTypes} - isArchiving={selection.isArchiving} - onConfirm={selection.handleBulkArchiveConfirm} - confirmTestId="sidebar-bulk-archive-confirm" - /> + <> + {archiveDialog.state && ( + !o && archiveDialog.setState(null)} + isBulkOperation + count={archiveDialog.state.ids.length} + taskIds={archiveDialog.state.ids} + executorTypes={archiveDialog.state.executorTypes} + isArchiving={selection.isArchiving} + onConfirm={archiveDialog.confirm} + confirmTestId="sidebar-bulk-archive-confirm" + /> + )} + {deleteDialog.state && ( + !o && deleteDialog.setState(null)} + isBulkOperation + count={deleteDialog.state.ids.length} + taskIds={deleteDialog.state.ids} + executorTypes={deleteDialog.state.executorTypes} + isDeleting={selection.isDeleting} + onConfirm={deleteDialog.confirm} + confirmTestId="sidebar-bulk-delete-confirm" + /> + )} + ); } diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 7263d13eaa..33576a9b3d 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -15,7 +15,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; -import { useSidebarSelection, SidebarBulkArchiveDialog } from "./task-session-sidebar-selection"; +import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { repositorySlug } from "@/lib/repository-slug"; @@ -673,7 +673,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ /> - + ); }); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 2f4e472a19..b36df43198 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -65,6 +65,8 @@ type ContextMenuProps = { /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ selectedTaskIds?: Set; onBulkArchive?: (taskIds: string[]) => void; + onBulkDelete?: (taskIds: string[]) => void; + onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ @@ -88,6 +90,8 @@ export function TaskItemWithContextMenu({ isDeleting, selectedTaskIds, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -123,6 +127,8 @@ export function TaskItemWithContextMenu({ isDeleting={isDeleting} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} + onBulkDelete={onBulkDelete} + onBulkPin={onBulkPin} onBulkMove={onBulkMove} onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} @@ -155,6 +161,8 @@ function TaskContextMenuItems({ isDeleting, selectedTaskIds, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -166,8 +174,31 @@ function TaskContextMenuItems({ // a non-selected row acts on just that task and leaves the selection intact. const actingOnSelection = !!selectedTaskIds?.has(task.id); const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id]; - // Delete has no bulk variant here, but deleting a selected row must drop it - // from the selection so later plain clicks navigate instead of toggling. + + // With several tasks selected, only actions that make sense for all of them + // are offered (Pin / Move / Archive / Delete) — the single-task actions + // (Rename, Color, Link, Duplicate) are hidden. + if (actingOnSelection && actingIds.length > 1) { + return ( + + ); + } + + // Delete of a lone selected row must drop it from the selection so later plain + // clicks navigate instead of toggling. const onDelete = actingOnSelection && onClearSelection ? (id: string) => { @@ -221,6 +252,76 @@ function TaskContextMenuItems({ ); } +/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */ +function BulkSelectionMenuItems({ + task, + actingIds, + workflows, + stepsByWorkflowId, + steps, + isMixedWorkflowSelection, + onBulkPin, + onBulkArchive, + onBulkDelete, + onBulkMove, + closeMenu, + moveTasks, +}: { + task: TaskSwitcherItem; + actingIds: string[]; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record; + steps?: StepDef[]; + isMixedWorkflowSelection?: boolean; + onBulkPin?: (taskIds: string[]) => void; + onBulkArchive?: (taskIds: string[]) => void; + onBulkDelete?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + closeMenu: () => void; + moveTasks: ReturnType; +}) { + const n = actingIds.length; + return ( + <> + {onBulkPin && ( + onBulkPin(actingIds)}> + + Pin {n} tasks + + )} + + + {onBulkDelete && ( + <> + + onBulkDelete(actingIds)}> + + Delete {n} tasks + + + )} + + ); +} + function selectTaskAction( taskId: string, handler: ((taskId: string) => void) | undefined, diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 49abb62ef8..90c937053c 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -75,6 +75,8 @@ type TaskSwitcherProps = { 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; @@ -192,6 +194,8 @@ type TaskRowProps = { 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; @@ -220,6 +224,8 @@ function TaskRow({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -245,6 +251,8 @@ function TaskRow({ isDeleting={deletingTaskId === task.id} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} + onBulkDelete={onBulkDelete} + onBulkPin={onBulkPin} onBulkMove={onBulkMove} onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} @@ -421,6 +429,8 @@ type GroupSectionProps = { 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; @@ -454,6 +464,8 @@ function GroupSection({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -482,6 +494,8 @@ function GroupSection({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -536,6 +550,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onToggleSelectTask, onSelectTaskRange, onBulkArchive, + onBulkDelete, + onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, @@ -584,6 +600,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onToggleSelectTask={onToggleSelectTask} onSelectTaskRange={onSelectTaskRange} onBulkArchive={onBulkArchive} + onBulkDelete={onBulkDelete} + onBulkPin={onBulkPin} onBulkMove={onBulkMove} onClearSelection={onClearSelection} isMixedWorkflowSelection={isMixedWorkflowSelection} diff --git a/apps/web/e2e/pages/sidebar-tasks-page.ts b/apps/web/e2e/pages/sidebar-tasks-page.ts index e896e6788c..3bc19373eb 100644 --- a/apps/web/e2e/pages/sidebar-tasks-page.ts +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -69,4 +69,20 @@ export class SidebarTasksPage { bulkArchiveConfirm(): Locator { return this.page.getByTestId("sidebar-bulk-archive-confirm"); } + + bulkPinMenuItem(count: number): Locator { + return this.page.getByRole("menuitem", { name: `Pin ${count} tasks` }); + } + + bulkDeleteMenuItem(count: number): Locator { + return this.page.getByRole("menuitem", { name: `Delete ${count} tasks` }); + } + + bulkDeleteConfirm(): Locator { + return this.page.getByTestId("sidebar-bulk-delete-confirm"); + } + + menuItem(name: string): Locator { + return this.page.getByRole("menuitem", { name, exact: true }); + } } diff --git a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts index b0ea27e8a5..6c45596679 100644 --- a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts @@ -164,6 +164,81 @@ test.describe("Sidebar selection-aware context menu", () => { await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 }); }); + test("multi-selection menu offers only bulk-valid actions", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, [ + "SB Menu Nav", + "SB Menu A", + "SB Menu B", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + await sidebar.rightClick(a.id); + + // Bulk-valid actions are present… + await expect(sidebar.bulkPinMenuItem(2)).toBeVisible({ timeout: 5_000 }); + await expect(sidebar.bulkArchiveMenuItem(2)).toBeVisible(); + await expect(sidebar.bulkDeleteMenuItem(2)).toBeVisible(); + // …and the single-task-only actions are hidden. + await expect(sidebar.menuItem("Rename")).not.toBeVisible(); + await expect(sidebar.menuItem("Duplicate")).not.toBeVisible(); + await testPage.keyboard.press("Escape"); + }); + + test("bulk Delete prompts for the selection and removes the rows", async ({ + testPage, + apiClient, + seedData, + }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, [ + "SB Del Nav", + "SB Del A", + "SB Del B", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + await sidebar.rightClick(a.id); + + const del = sidebar.bulkDeleteMenuItem(2); + await expect(del).toBeVisible({ timeout: 5_000 }); + await del.click(); + + const confirm = sidebar.bulkDeleteConfirm(); + await expect(confirm).toBeVisible({ timeout: 5_000 }); + await confirm.click(); + + await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 }); + }); + + test("bulk Pin pins all selected rows", async ({ testPage, apiClient, seedData }) => { + const [nav, a, b] = await seedTasks(apiClient, seedData, [ + "SB Pin Nav", + "SB Pin A", + "SB Pin B", + ]); + const sidebar = await openSidebarOn(testPage, nav.id); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + await sidebar.rightClick(a.id); + + const pin = sidebar.bulkPinMenuItem(2); + await expect(pin).toBeVisible({ timeout: 5_000 }); + await pin.click(); + + // Both rows now render the pinned icon. + await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 }); + await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 }); + }); + test("right-clicking an unselected row acts on that row only", async ({ testPage, apiClient, diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts index ef8b340c5f..def0d8e13c 100644 --- a/apps/web/hooks/use-sidebar-multi-select.test.ts +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -2,20 +2,25 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const archiveTaskById = vi.fn(); +const deleteTaskById = vi.fn(); const archiveAndSwitch = vi.fn(); +const removeTaskFromBoard = vi.fn(); const removeTasksFromStore = vi.fn(); const moveTasks = vi.fn(); const toast = vi.fn(); let activeTaskId: string | null = null; vi.mock("./use-task-actions", () => ({ - useTaskActions: () => ({ archiveTaskById }), + useTaskActions: () => ({ archiveTaskById, deleteTaskById }), useArchiveAndSwitchTask: () => archiveAndSwitch, })); +vi.mock("./use-task-removal", () => ({ useTaskRemoval: () => ({ removeTaskFromBoard }) })); vi.mock("./use-task-workflow-move", () => ({ useTaskWorkflowMove: () => moveTasks })); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast }) })); vi.mock("@/components/state-provider", () => ({ - useAppStoreApi: () => ({ getState: () => ({ tasks: { activeTaskId } }) }), + useAppStoreApi: () => ({ + getState: () => ({ tasks: { activeTaskId, activeSessionId: null } }), + }), })); vi.mock("./use-task-multi-select", async (importOriginal) => { const actual = await importOriginal(); @@ -27,7 +32,9 @@ import { useSidebarMultiSelect } from "./use-sidebar-multi-select"; beforeEach(() => { activeTaskId = null; archiveTaskById.mockReset().mockResolvedValue(undefined); + deleteTaskById.mockReset().mockResolvedValue(undefined); archiveAndSwitch.mockReset().mockResolvedValue(undefined); + removeTaskFromBoard.mockReset().mockResolvedValue(undefined); removeTasksFromStore.mockReset(); moveTasks.mockReset().mockResolvedValue(undefined); toast.mockReset(); @@ -85,7 +92,9 @@ describe("useSidebarMultiSelect", () => { rerender({ ws: "ws2" }); expect(result.current.selectedIds.size).toBe(0); }); +}); +describe("useSidebarMultiSelect — bulk actions", () => { it("bulkArchive removes all on full success and clears the selection", async () => { const { result } = renderHook(() => useSidebarMultiSelect("ws1")); await act(async () => { @@ -130,6 +139,44 @@ describe("useSidebarMultiSelect", () => { expect(archiveTaskById).not.toHaveBeenCalled(); }); + it("bulkDelete removes all on full success and clears the selection", async () => { + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkDelete(["a", "b"]); + }); + expect(deleteTaskById).toHaveBeenCalledTimes(2); + expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"])); + expect(result.current.selectedIds.size).toBe(0); + expect(toast).not.toHaveBeenCalled(); + }); + + it("bulkDelete keeps failed ids selected and toasts on partial failure", async () => { + deleteTaskById.mockImplementation((id: string) => + id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(), + ); + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkDelete(["a", "b"]); + }); + expect(result.current.selectedIds).toEqual(new Set(["b"])); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + }); + + it("bulkDelete routes the active task through the switch-aware removal", async () => { + activeTaskId = "a"; + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkDelete(["a", "b"]); + }); + // 'b' deleted directly; 'a' (active) deleted then removed-from-board to switch. + expect(deleteTaskById).toHaveBeenCalledWith("b", undefined); + expect(deleteTaskById).toHaveBeenCalledWith("a", undefined); + expect(removeTaskFromBoard).toHaveBeenCalledWith( + "a", + expect.objectContaining({ wasActiveTaskId: "a" }), + ); + }); + it("bulkMove clears the selection on success", async () => { const { result } = renderHook(() => useSidebarMultiSelect("ws1")); act(() => result.current.toggleSelect("a")); diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index dce8fe83cb..421e223c41 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -1,104 +1,110 @@ "use client"; -import { useCallback, useEffect, useReducer, useState } from "react"; +import { useCallback, useEffect, useReducer, useState, type Dispatch } from "react"; import { INITIAL_STATE, multiSelectReducer, useTaskMultiSelectStore, } from "./use-task-multi-select"; import { useTaskActions, useArchiveAndSwitchTask } from "./use-task-actions"; +import { useTaskRemoval } from "./use-task-removal"; import { useTaskWorkflowMove } from "./use-task-workflow-move"; import { useToast } from "@/components/toast-provider"; import { useAppStoreApi } from "@/components/state-provider"; +type BulkOpts = { cascade?: boolean }; + /** - * Sidebar task multi-select. Reuses the shared selection reducer (toggle + - * shift-range with an anchor) from `use-task-multi-select`, but exposes only the - * pieces the sidebar needs: selection state plus bulk archive/move that act on - * an explicit id list (driven by the right-click context menu, not a toolbar). - * - * Unlike the kanban hook this spans all workflows in a workspace, so it resets - * on workspace change and the caller passes the target workflow for moves. + * Bulk archive/delete/move that act on an explicit id list. Archive and delete + * share one flow: remove each task, routing the currently-open task through the + * switch-aware path last so the view doesn't strand on removed content; keep any + * failed ids selected for retry and surface the failure via toast. */ -export function useSidebarMultiSelect(workspaceId: string | null) { - const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE); - const { selectedIds } = state; - const [isArchiving, setIsArchiving] = useState(false); - - // Selection is ephemeral per workspace; drop it when the workspace changes. - useEffect(() => { - dispatch({ type: "reset" }); - }, [workspaceId]); - +function useSidebarBulkActions( + dispatch: Dispatch<{ type: "set_selected"; ids: Set }>, + clearSelection: () => void, +) { const store = useAppStoreApi(); - const { archiveTaskById } = useTaskActions(); + const { archiveTaskById, deleteTaskById } = useTaskActions(); const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); + const { removeTaskFromBoard } = useTaskRemoval({ store, useLayoutSwitch: true }); const { removeTasksFromStore } = useTaskMultiSelectStore(); const moveTasks = useTaskWorkflowMove(); const { toast } = useToast(); + const [isArchiving, setIsArchiving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - const toggleSelect = useCallback( - (taskId: string) => dispatch({ type: "toggle_select", taskId }), - [], - ); - - const selectRange = useCallback( - (taskId: string, orderedIds: string[]) => - dispatch({ type: "select_range", taskId, orderedIds }), - [], - ); - - const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []); - - // Drop any selected ids that are no longer visible (collapsed group / filtered - // out) so `isSelecting` doesn't stay latched on hidden rows. No-ops (and avoids - // a render loop) when every selected id is still visible. - const pruneToVisible = useCallback( - (visibleIds: string[]) => { - const visible = new Set(visibleIds); - const next = new Set([...selectedIds].filter((id) => visible.has(id))); - if (next.size !== selectedIds.size) dispatch({ type: "set_selected", ids: next }); - }, - [selectedIds], - ); - - const bulkArchive = useCallback( - async (ids: string[], opts?: { cascade?: boolean }) => { + const runBulkRemoval = useCallback( + async ( + ids: string[], + per: (id: string) => Promise, + handleActive: (id: string) => Promise, + setBusy: (v: boolean) => void, + noun: string, + ) => { if (ids.length === 0) return; - // If the open task is in the set, archive it via the switch-aware path last - // so the URL/layout moves off it instead of showing stale content. const activeId = store.getState().tasks.activeTaskId; const activeInSet = activeId != null && ids.includes(activeId); const restIds = activeInSet ? ids.filter((id) => id !== activeId) : ids; - setIsArchiving(true); + setBusy(true); try { - const results = await Promise.allSettled(restIds.map((id) => archiveTaskById(id, opts))); + const results = await Promise.allSettled(restIds.map((id) => per(id))); const failed = restIds.filter((_, i) => results[i].status === "rejected"); const succeeded = restIds.filter((_, i) => results[i].status === "fulfilled"); if (succeeded.length > 0) removeTasksFromStore(new Set(succeeded)); if (activeInSet) { try { - await archiveAndSwitch(activeId!, opts); + await handleActive(activeId!); } catch { failed.push(activeId!); } } if (failed.length > 0) { - // Mirror the kanban hook: keep the failed ids selected so the user can - // retry, and surface the failure instead of silently clearing. + // Keep the failed ids selected so the user can retry, and surface it. dispatch({ type: "set_selected", ids: new Set(failed) }); toast({ - title: `Failed to archive ${failed.length} task${failed.length === 1 ? "" : "s"}`, + title: `Failed to ${noun} ${failed.length} task${failed.length === 1 ? "" : "s"}`, variant: "error", }); return; } clearSelection(); } finally { - setIsArchiving(false); + setBusy(false); } }, - [store, archiveTaskById, archiveAndSwitch, removeTasksFromStore, clearSelection, toast], + [store, removeTasksFromStore, dispatch, toast, clearSelection], + ); + + const bulkArchive = useCallback( + (ids: string[], opts?: BulkOpts) => + runBulkRemoval( + ids, + (id) => archiveTaskById(id, opts), + (id) => archiveAndSwitch(id, opts), + setIsArchiving, + "archive", + ), + [runBulkRemoval, archiveTaskById, archiveAndSwitch], + ); + + const bulkDelete = useCallback( + (ids: string[], opts?: BulkOpts) => + runBulkRemoval( + ids, + (id) => deleteTaskById(id, opts), + async (id) => { + const { activeTaskId, activeSessionId } = store.getState().tasks; + await deleteTaskById(id, opts); + await removeTaskFromBoard(id, { + wasActiveTaskId: activeTaskId, + wasActiveSessionId: activeSessionId, + }); + }, + setIsDeleting, + "delete", + ), + [runBulkRemoval, deleteTaskById, removeTaskFromBoard, store], ); const bulkMove = useCallback( @@ -116,15 +122,64 @@ export function useSidebarMultiSelect(workspaceId: string | null) { [moveTasks, clearSelection], ); + return { bulkArchive, bulkDelete, bulkMove, isArchiving, isDeleting }; +} + +/** + * Sidebar task multi-select. Reuses the shared selection reducer (toggle + + * shift-range with an anchor) from `use-task-multi-select`, and exposes the + * selection state plus bulk archive/delete/move used by the right-click menu. + * + * Unlike the kanban hook this spans all workflows in a workspace, so it resets + * on workspace change and the caller passes the target workflow for moves. + */ +export function useSidebarMultiSelect(workspaceId: string | null) { + const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE); + const { selectedIds } = state; + + // Selection is ephemeral per workspace; drop it when the workspace changes. + useEffect(() => { + dispatch({ type: "reset" }); + }, [workspaceId]); + + const toggleSelect = useCallback( + (taskId: string) => dispatch({ type: "toggle_select", taskId }), + [], + ); + + const selectRange = useCallback( + (taskId: string, orderedIds: string[]) => + dispatch({ type: "select_range", taskId, orderedIds }), + [], + ); + + const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []); + + // Drop any selected ids that are no longer visible (collapsed group / filtered + // out) so `isSelecting` doesn't stay latched on hidden rows. No-ops (and avoids + // a render loop) when every selected id is still visible. + const pruneToVisible = useCallback( + (visibleIds: string[]) => { + const visible = new Set(visibleIds); + const next = new Set([...selectedIds].filter((id) => visible.has(id))); + if (next.size !== selectedIds.size) dispatch({ type: "set_selected", ids: next }); + }, + [selectedIds], + ); + + const bulk = useSidebarBulkActions(dispatch, clearSelection); + return { selectedIds, isSelecting: selectedIds.size > 0, - isArchiving, + isArchiving: bulk.isArchiving, + isDeleting: bulk.isDeleting, toggleSelect, selectRange, clearSelection, pruneToVisible, - bulkArchive, - bulkMove, + bulkArchive: bulk.bulkArchive, + bulkDelete: bulk.bulkDelete, + bulkMove: bulk.bulkMove, }; } diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts index 81102b74f7..538d6893e6 100644 --- a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -63,6 +63,24 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet, get: () => UISlice) }); syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); }, + // Pin every id that isn't already pinned (bulk "Pin", not a per-id toggle). + pinTasks: (taskIds: string[]) => { + let changed = false; + set((draft) => { + const list = draft.sidebarTaskPrefs.pinnedTaskIds; + for (const id of taskIds) { + if (!list.includes(id)) { + list.push(id); + changed = true; + } + } + if (changed) { + draft.sidebarTaskPrefs.syncPending = true; + setStoredPinnedTaskIds(list); + } + }); + if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, setSidebarTaskOrder: (orderedTaskIds: string[]) => { set((draft) => { draft.sidebarTaskPrefs.syncPending = true; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index eb30e9929b..f82bd8956e 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -234,6 +234,8 @@ export type UISliceActions = { migrateLocalViewsToBackend: () => void; setKanbanPreviewedTaskId: (taskId: string | null) => void; togglePinnedTask: (taskId: string) => void; + /** Pin every id that isn't already pinned (bulk "Pin" for multi-select). */ + pinTasks: (taskIds: string[]) => void; setSidebarTaskOrder: (orderedTaskIds: string[]) => void; /** Replace the stored subtask order for a parent task. Empty array clears it. */ setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index bcd348d3ff..67dfd6cb37 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -117,6 +117,20 @@ describe("sidebar task prefs (pin + manual order)", () => { expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]); }); + it("pinTasks pins all given ids without unpinning existing ones", () => { + const store = makeStore(); + store.getState().togglePinnedTask("t1"); + + store.getState().pinTasks(["t1", "t2", "t3"]); + // t1 already pinned stays pinned; t2/t3 added. + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1", "t2", "t3"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual([ + "t1", + "t2", + "t3", + ]); + }); + it("setSidebarTaskOrder replaces and persists", () => { const store = makeStore(); store.getState().setSidebarTaskOrder(["a", "b", "c"]); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index e8f59bd71a..f0b82f7548 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -526,6 +526,7 @@ export type AppState = { migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"]; setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"]; togglePinnedTask: UIA["togglePinnedTask"]; + pinTasks: UIA["pinTasks"]; setSidebarTaskOrder: UIA["setSidebarTaskOrder"]; setSubtaskOrder: UIA["setSubtaskOrder"]; removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"]; From 4b615fab9ccf6a5156dbd414f490574272a6f49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 1 Jul 2026 14:03:50 +0100 Subject: [PATCH 15/19] fix: harden multi-select bulk ordering, selection clearing, and a11y --- apps/web/components/kanban-card.tsx | 27 ++++++++++--------- apps/web/components/task/task-item.tsx | 15 ++++++++--- .../task/task-session-sidebar-selection.tsx | 5 ++-- .../task/task-switcher-context-menu.tsx | 15 ++++++----- apps/web/e2e/pages/sidebar-tasks-page.ts | 8 +++--- apps/web/lib/kanban/task-order.test.ts | 9 +++++-- apps/web/lib/kanban/task-order.ts | 12 ++++----- 7 files changed, 55 insertions(+), 36 deletions(-) diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 83d2ddfc94..c587e0b949 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -137,18 +137,17 @@ function useKanbanCardMenus({ } }; - // Sort into board order so a backward range selection isn't scrambled when the - // bulk move assigns sequential positions in request order. - const selectedTaskIds = sortByDisplayOrder( - isSelected && selectedIds?.size ? [...selectedIds] : [task.id], - ); + const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id]; + // Sort into board order lazily (only when a move actually fires) so a backward + // range selection isn't scrambled — and we don't pay it on every card render. + const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds); const moveSelectedToStep = (stepId: string) => { if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) { onMove(task, stepId); return; } if (!moveTargets.currentWorkflowId) return; - runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId); + runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId); }; const menuBase = { @@ -178,7 +177,7 @@ function useKanbanCardMenus({ ...menuBase, onMoveToStep: moveSelectedToStep, onSendToWorkflow: (workflowId, stepId) => { - runMoveTasks(selectedTaskIds, workflowId, stepId); + runMoveTasks(orderedSelectedIds(), workflowId, stepId); }, }), showDeleteConfirm, @@ -276,18 +275,20 @@ export function dispatchKanbanCardClick( isMultiSelectMode?: boolean; }, ): void { - if (e.metaKey || e.ctrlKey) { + // Only intercept a modifier click when the matching handler is wired, so a + // card rendered without selection handlers still opens on Cmd/Shift click. + if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelect) { e.preventDefault(); - handlers.onToggleSelect?.(taskId); + handlers.onToggleSelect(taskId); return; } - if (e.shiftKey) { + if (e.shiftKey && handlers.onRangeSelect) { e.preventDefault(); - handlers.onRangeSelect?.(taskId); + handlers.onRangeSelect(taskId); return; } - if (handlers.isMultiSelectMode) { - handlers.onToggleSelect?.(taskId); + if (handlers.isMultiSelectMode && handlers.onToggleSelect) { + handlers.onToggleSelect(taskId); return; } handlers.onClick?.(task); diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index a2dcd88196..2307bba8dc 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -123,6 +123,17 @@ function handleTaskItemKeyDown( else onClick?.(); } +/** State attributes for the row: active-task (`aria-current`) + multi-selected. */ +function taskItemStateAttrs(isSelected: boolean, isMultiSelected: boolean) { + return { + "data-active": isSelected ? "true" : "false", + "data-multiselected": isMultiSelected ? "true" : undefined, + "aria-current": isSelected ? ("true" as const) : undefined, + // Surface the multi-selected state to assistive tech. + "aria-selected": isMultiSelected ? true : undefined, + }; +} + function taskItemRowClassName( isSelected: boolean, isMultiSelected: boolean, @@ -424,9 +435,7 @@ export const TaskItem = memo(function TaskItem({ role="button" tabIndex={0} data-testid="sidebar-task-item" - data-active={isSelected ? "true" : "false"} - data-multiselected={isMultiSelected ? "true" : undefined} - aria-current={isSelected ? "true" : undefined} + {...taskItemStateAttrs(isSelected, isMultiSelected)} onClick={taskItemRowClick(onSelect, onClick)} onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)} style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined} diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 95d0077f8a..971a1257b1 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -105,10 +105,11 @@ function useSelectionHandlers(args: { const onBulkPin = useCallback( (ids: string[]) => { - pinTasks(ids); + // Pin in visible order so pinned rows keep the order the user saw. + pinTasks(sortIdsByVisibleOrder(ids, visibleTaskIds)); clearSelection(); }, - [pinTasks, clearSelection], + [pinTasks, clearSelection, visibleTaskIds], ); return { onSelectTaskRange, onBulkMove, onBulkPin }; diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index b36df43198..e405374851 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -197,22 +197,23 @@ function TaskContextMenuItems({ ); } - // Delete of a lone selected row must drop it from the selection so later plain - // clicks navigate instead of toggling. - const onDelete = - actingOnSelection && onClearSelection + // Acting on a lone selected row (Pin / Delete) must drop it from the selection + // so later plain clicks navigate instead of toggling. + const withClear = (handler?: (id: string) => void) => + actingOnSelection && onClearSelection && handler ? (id: string) => { onClearSelection(); - onDeleteTask?.(id); + handler(id); } - : onDeleteTask; + : handler; + const onDelete = withClear(onDeleteTask); return ( <> diff --git a/apps/web/e2e/pages/sidebar-tasks-page.ts b/apps/web/e2e/pages/sidebar-tasks-page.ts index 3bc19373eb..f3ed2e892f 100644 --- a/apps/web/e2e/pages/sidebar-tasks-page.ts +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -1,5 +1,7 @@ import { expect, type Locator, type Page } from "@playwright/test"; +const plural = (count: number) => (count === 1 ? "task" : "tasks"); + /** * Page object for the task list inside the unified AppSidebar (TaskSessionSidebar). * Covers cmd/shift-click multi-select and the selection-aware right-click menu. @@ -59,7 +61,7 @@ export class SidebarTasksPage { // --- right-click bulk menu --- bulkArchiveMenuItem(count: number): Locator { - return this.page.getByRole("menuitem", { name: `Archive ${count} tasks` }); + return this.page.getByRole("menuitem", { name: `Archive ${count} ${plural(count)}` }); } singleArchiveMenuItem(): Locator { @@ -71,11 +73,11 @@ export class SidebarTasksPage { } bulkPinMenuItem(count: number): Locator { - return this.page.getByRole("menuitem", { name: `Pin ${count} tasks` }); + return this.page.getByRole("menuitem", { name: `Pin ${count} ${plural(count)}` }); } bulkDeleteMenuItem(count: number): Locator { - return this.page.getByRole("menuitem", { name: `Delete ${count} tasks` }); + return this.page.getByRole("menuitem", { name: `Delete ${count} ${plural(count)}` }); } bulkDeleteConfirm(): Locator { diff --git a/apps/web/lib/kanban/task-order.test.ts b/apps/web/lib/kanban/task-order.test.ts index 221d5f5353..06bc95b4d7 100644 --- a/apps/web/lib/kanban/task-order.test.ts +++ b/apps/web/lib/kanban/task-order.test.ts @@ -68,7 +68,12 @@ describe("sortIdsByCreatedDesc", () => { expect(sortIdsByCreatedDesc(["a", "c", "b"], taskById)).toEqual(["c", "b", "a"]); }); - it("keeps relative order for ids without a known task", () => { - expect(sortIdsByCreatedDesc(["zzz", "a"], taskById)).toEqual(["zzz", "a"]); + it("sorts ids without a known task last (transitive fallback)", () => { + expect(sortIdsByCreatedDesc(["zzz", "a"], taskById)).toEqual(["a", "zzz"]); + }); + + it("stays transitive when only some ids are missing", () => { + // c (newest present) < a (older present) < missing → deterministic order. + expect(sortIdsByCreatedDesc(["a", "missing", "c"], taskById)).toEqual(["c", "a", "missing"]); }); }); diff --git a/apps/web/lib/kanban/task-order.ts b/apps/web/lib/kanban/task-order.ts index 737388ca9b..470924d930 100644 --- a/apps/web/lib/kanban/task-order.ts +++ b/apps/web/lib/kanban/task-order.ts @@ -23,10 +23,10 @@ export function compareTasksByCreatedDesc(a: CreatedTask, b: CreatedTask): numbe * sequential positions are assigned. */ export function sortIdsByCreatedDesc(ids: string[], taskById: Map): string[] { - return [...ids].sort((a, b) => { - const ta = taskById.get(a); - const tb = taskById.get(b); - if (!ta || !tb) return 0; - return compareTasksByCreatedDesc(ta, tb); - }); + // Missing ids fall back to `{}`, which `compareTasksByCreatedDesc` treats as + // the oldest (sorts last) — keeping the comparator transitive rather than + // returning 0 whenever either side is unknown. + return [...ids].sort((a, b) => + compareTasksByCreatedDesc(taskById.get(a) ?? {}, taskById.get(b) ?? {}), + ); } From db6c18e6385e1e0ec8ef2b3f19f3233abad87627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 1 Jul 2026 14:33:25 +0100 Subject: [PATCH 16/19] fix: gate kanban card move-to-step for mixed-workflow selections --- apps/web/components/kanban-card.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index c587e0b949..69b2823388 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -114,7 +114,7 @@ function useKanbanCardMenus({ >) { const moveTargets = useKanbanCardMoveTargets(task.id, steps); const moveTasks = useTaskWorkflowMove(); - const { sortByDisplayOrder } = useTaskMultiSelectStore(); + const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); const [showPRDialog, setShowPRDialog] = useState(false); @@ -141,6 +141,12 @@ function useKanbanCardMenus({ // Sort into board order lazily (only when a move actually fires) so a backward // range selection isn't scrambled — and we don't pay it on every card render. const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds); + // A selection spanning workflows can't safely use same-workflow "Move to" (it + // would drag other-workflow cards into this card's workflow), so gate it — only + // computed for a genuine multi-selection to avoid per-render snapshot scans. + const isMixedWorkflowSelection = + selectedTaskIds.length > 1 && + new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1; const moveSelectedToStep = (stepId: string) => { if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) { onMove(task, stepId); @@ -175,7 +181,9 @@ function useKanbanCardMenus({ }), contextMenuEntries: buildKanbanCardMenuEntries({ ...menuBase, - onMoveToStep: moveSelectedToStep, + // Hide same-workflow "Move to" for a mixed-workflow selection; only the + // explicit "Send to workflow" path remains (matches the toolbar guard). + onMoveToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep, onSendToWorkflow: (workflowId, stepId) => { runMoveTasks(orderedSelectedIds(), workflowId, stepId); }, From 5f09827310719a7f00b5e206e90113c08d9e44db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 6 Jul 2026 16:11:12 +0100 Subject: [PATCH 17/19] fix: unpin fully pinned sidebar selections --- .../task-session-sidebar-archived-item.ts | 36 ++++++++++++++++ .../task/task-session-sidebar-selection.tsx | 17 ++++++-- .../components/task/task-session-sidebar.tsx | 41 ++----------------- .../task/task-switcher-context-menu.tsx | 18 +++++++- .../components/task/task-switcher.test.tsx | 29 ++++++++++++- apps/web/components/task/task-switcher.tsx | 7 ++++ apps/web/e2e/pages/sidebar-tasks-page.ts | 4 ++ .../tests/task/sidebar-multi-select.spec.ts | 15 +++++++ .../slices/ui/sidebar-task-prefs-actions.ts | 15 +++++++ apps/web/lib/state/slices/ui/types.ts | 2 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 24 +++++++++-- apps/web/lib/state/store.ts | 1 + 12 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 apps/web/components/task/task-session-sidebar-archived-item.ts diff --git a/apps/web/components/task/task-session-sidebar-archived-item.ts b/apps/web/components/task/task-session-sidebar-archived-item.ts new file mode 100644 index 0000000000..10388fd192 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-archived-item.ts @@ -0,0 +1,36 @@ +import type { TaskSwitcherItem } from "./task-switcher"; +import type { useArchivedTaskState } from "./task-archived-context"; + +export function buildArchivedSidebarItem( + s: ReturnType, +): TaskSwitcherItem { + return { + id: s.archivedTaskId!, + title: s.archivedTaskTitle ?? "Archived task", + state: undefined, + sessionState: undefined, + description: undefined, + workflowId: undefined, + workflowName: undefined, + workflowStepId: undefined, + workflowStepTitle: undefined, + repositoryPath: s.archivedTaskRepositoryPath, + diffStats: undefined, + isRemoteExecutor: false, + remoteExecutorType: undefined, + remoteExecutorName: undefined, + primarySessionId: null, + hasPendingClarification: false, + hasPendingPermission: false, + updatedAt: s.archivedTaskUpdatedAt, + createdAt: undefined, + isArchived: true, + parentTaskTitle: undefined, + parentTaskId: undefined, + prInfo: undefined, + isPRReview: false, + isIssueWatch: false, + issueInfo: undefined, + agentErrorMessage: null, + }; +} diff --git a/apps/web/components/task/task-session-sidebar-selection.tsx b/apps/web/components/task/task-session-sidebar-selection.tsx index 971a1257b1..450ef37b87 100644 --- a/apps/web/components/task/task-session-sidebar-selection.tsx +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -60,10 +60,13 @@ type Multi = ReturnType; function useSelectionHandlers(args: { multiSelect: Multi; pinTasks: (ids: string[]) => void; + unpinTasks: (ids: string[]) => void; + pinnedTaskIds: string[]; visibleTaskIds: string[]; movableSelectedIds: Set; }) { - const { multiSelect, pinTasks, visibleTaskIds, movableSelectedIds } = args; + const { multiSelect, pinTasks, unpinTasks, pinnedTaskIds, visibleTaskIds, movableSelectedIds } = + args; const { isSelecting, clearSelection, selectRange, pruneToVisible } = multiSelect; // Escape clears an active selection. @@ -105,11 +108,15 @@ function useSelectionHandlers(args: { const onBulkPin = useCallback( (ids: string[]) => { + const orderedIds = sortIdsByVisibleOrder(ids, visibleTaskIds); + const pinnedSet = new Set(pinnedTaskIds); + const allPinned = orderedIds.length > 0 && orderedIds.every((id) => pinnedSet.has(id)); // Pin in visible order so pinned rows keep the order the user saw. - pinTasks(sortIdsByVisibleOrder(ids, visibleTaskIds)); + if (allPinned) unpinTasks(orderedIds); + else pinTasks(orderedIds); clearSelection(); }, - [pinTasks, clearSelection, visibleTaskIds], + [pinTasks, unpinTasks, pinnedTaskIds, clearSelection, visibleTaskIds], ); return { onSelectTaskRange, onBulkMove, onBulkPin }; @@ -137,6 +144,8 @@ export function useSidebarSelection({ const multiSelect = useSidebarMultiSelect(workspaceId); const { selectedIds, clearSelection, toggleSelect } = multiSelect; const pinTasks = useAppStore((s) => s.pinTasks); + const unpinTasks = useAppStore((s) => s.unpinTasks); + const pinnedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.pinnedTaskIds); const archiveDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkArchive); const deleteDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkDelete); @@ -156,6 +165,8 @@ export function useSidebarSelection({ const { onSelectTaskRange, onBulkMove, onBulkPin } = useSelectionHandlers({ multiSelect, pinTasks, + unpinTasks, + pinnedTaskIds, visibleTaskIds, movableSelectedIds, }); diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 33576a9b3d..6368bf0673 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -7,7 +7,7 @@ import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib import type { TaskPR } from "@/lib/types/github"; import type { KanbanState } from "@/lib/state/slices"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; -import { TaskSwitcher } from "./task-switcher"; +import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar"; import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data"; import { SidebarDialogs } from "./task-session-sidebar-dialogs"; @@ -28,6 +28,7 @@ import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggregate"; import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view"; import { useSidebarLinkActions } from "./task-session-sidebar-link-actions"; +import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item"; import { useShallow } from "zustand/react/shallow"; import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error"; import { @@ -161,40 +162,6 @@ type TaskSessionSidebarProps = { hideFilterBar?: boolean; }; -type SidebarItem = Omit, "workflowId"> & { workflowId?: string }; - -function buildArchivedItem(s: ReturnType): SidebarItem { - return { - id: s.archivedTaskId!, - title: s.archivedTaskTitle ?? "Archived task", - state: undefined, - sessionState: undefined, - description: undefined, - workflowId: undefined, - workflowName: undefined, - workflowStepId: undefined, - workflowStepTitle: undefined, - repositoryPath: s.archivedTaskRepositoryPath, - diffStats: undefined, - isRemoteExecutor: false, - remoteExecutorType: undefined, - remoteExecutorName: undefined, - primarySessionId: null, - hasPendingClarification: false, - hasPendingPermission: false, - updatedAt: s.archivedTaskUpdatedAt, - createdAt: undefined, - isArchived: true, - parentTaskTitle: undefined, - parentTaskId: undefined, - prInfo: undefined, - isPRReview: false, - isIssueWatch: false, - issueInfo: undefined, - agentErrorMessage: null, - }; -} - function useSidebarData(workspaceId: string | null) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); @@ -263,13 +230,13 @@ function useSidebarData(workspaceId: string | null) { acknowledgedAgentErrors, messagesBySession, }; - const items: SidebarItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx)); + const items: TaskSwitcherItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx)); if ( archivedState.isArchived && archivedState.archivedTaskId && !items.some((t) => t.id === archivedState.archivedTaskId) ) { - items.unshift(buildArchivedItem(archivedState)); + items.unshift(buildArchivedSidebarItem(archivedState)); } return items; }, [ diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index e405374851..6108b8ef91 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -61,6 +61,7 @@ type ContextMenuProps = { onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; + pinnedTaskIds?: string[]; isDeleting?: boolean; /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ selectedTaskIds?: Set; @@ -87,6 +88,7 @@ export function TaskItemWithContextMenu({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, isDeleting, selectedTaskIds, onBulkArchive, @@ -124,6 +126,7 @@ export function TaskItemWithContextMenu({ onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} isPinned={isPinned} + pinnedTaskIds={pinnedTaskIds} isDeleting={isDeleting} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} @@ -158,6 +161,7 @@ function TaskContextMenuItems({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, isDeleting, selectedTaskIds, onBulkArchive, @@ -187,6 +191,7 @@ function TaskContextMenuItems({ stepsByWorkflowId={stepsByWorkflowId} steps={steps} isMixedWorkflowSelection={isMixedWorkflowSelection} + pinnedTaskIds={pinnedTaskIds} onBulkPin={onBulkPin} onBulkArchive={onBulkArchive} onBulkDelete={onBulkDelete} @@ -261,6 +266,7 @@ function BulkSelectionMenuItems({ stepsByWorkflowId, steps, isMixedWorkflowSelection, + pinnedTaskIds, onBulkPin, onBulkArchive, onBulkDelete, @@ -274,6 +280,7 @@ function BulkSelectionMenuItems({ stepsByWorkflowId?: Record; steps?: StepDef[]; isMixedWorkflowSelection?: boolean; + pinnedTaskIds?: string[]; onBulkPin?: (taskIds: string[]) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; @@ -282,12 +289,19 @@ function BulkSelectionMenuItems({ moveTasks: ReturnType; }) { const n = actingIds.length; + const allPinned = + actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false); + const pinLabel = `${allPinned ? "Unpin" : "Pin"} ${n} ${n === 1 ? "task" : "tasks"}`; return ( <> {onBulkPin && ( onBulkPin(actingIds)}> - - Pin {n} tasks + {allPinned ? ( + + ) : ( + + )} + {pinLabel} )} { } }); }); + +describe("TaskSwitcher — bulk pin menu", () => { + it("offers bulk unpin when every selected root task is pinned", () => { + render( + + + , + ); + + fireEvent.contextMenu(screen.getByText(TASK_A.title)); + + expect(screen.getByText("Unpin 2 tasks")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 90c937053c..d890bfea97 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -189,6 +189,7 @@ type TaskRowProps = { 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; @@ -219,6 +220,7 @@ function TaskRow({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, deletingTaskId, selectedTaskIds, onToggleSelectTask, @@ -248,6 +250,7 @@ function TaskRow({ onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} isPinned={isPinned} + pinnedTaskIds={pinnedTaskIds} isDeleting={deletingTaskId === task.id} selectedTaskIds={selectedTaskIds} onBulkArchive={onBulkArchive} @@ -423,6 +426,7 @@ type GroupSectionProps = { onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void; + pinnedTaskIds?: string[]; pinnedSet: Set; deletingTaskId?: string | null; selectedTaskIds?: Set; @@ -458,6 +462,7 @@ function GroupSection({ onTogglePin, onReorderGroup, onReorderSubtasks, + pinnedTaskIds, pinnedSet, deletingTaskId, selectedTaskIds, @@ -489,6 +494,7 @@ function GroupSection({ onLinkIssue, onMoveToStep, onTogglePin, + pinnedTaskIds, deletingTaskId, selectedTaskIds, onToggleSelectTask, @@ -594,6 +600,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onTogglePin={onTogglePin} onReorderGroup={onReorderGroup} onReorderSubtasks={onReorderSubtasks} + pinnedTaskIds={pinnedTaskIds} pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} selectedTaskIds={selectedTaskIds} diff --git a/apps/web/e2e/pages/sidebar-tasks-page.ts b/apps/web/e2e/pages/sidebar-tasks-page.ts index f3ed2e892f..9f2d43e7f7 100644 --- a/apps/web/e2e/pages/sidebar-tasks-page.ts +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -76,6 +76,10 @@ export class SidebarTasksPage { return this.page.getByRole("menuitem", { name: `Pin ${count} ${plural(count)}` }); } + bulkUnpinMenuItem(count: number): Locator { + return this.page.getByRole("menuitem", { name: `Unpin ${count} ${plural(count)}` }); + } + bulkDeleteMenuItem(count: number): Locator { return this.page.getByRole("menuitem", { name: `Delete ${count} ${plural(count)}` }); } diff --git a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts index 6c45596679..cfc24d1eab 100644 --- a/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts @@ -237,6 +237,21 @@ test.describe("Sidebar selection-aware context menu", () => { // Both rows now render the pinned icon. await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 }); await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 }); + + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + await sidebar.rightClick(a.id); + + const unpin = sidebar.bulkUnpinMenuItem(2); + await expect(unpin).toBeVisible({ timeout: 5_000 }); + await unpin.click(); + + await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeHidden({ + timeout: 5_000, + }); + await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeHidden({ + timeout: 5_000, + }); }); test("right-clicking an unselected row acts on that row only", async ({ diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts index 538d6893e6..c64dab50ea 100644 --- a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -81,6 +81,21 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet, get: () => UISlice) }); if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); }, + // Unpin every matching id in one state update (bulk "Unpin"). + unpinTasks: (taskIds: string[]) => { + const toRemove = new Set(taskIds); + let changed = false; + set((draft) => { + const list = draft.sidebarTaskPrefs.pinnedTaskIds; + const next = list.filter((id) => !toRemove.has(id)); + if (next.length === list.length) return; + changed = true; + draft.sidebarTaskPrefs.syncPending = true; + draft.sidebarTaskPrefs.pinnedTaskIds = next; + setStoredPinnedTaskIds(next); + }); + if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, setSidebarTaskOrder: (orderedTaskIds: string[]) => { set((draft) => { draft.sidebarTaskPrefs.syncPending = true; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index f82bd8956e..8500899899 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -236,6 +236,8 @@ export type UISliceActions = { togglePinnedTask: (taskId: string) => void; /** Pin every id that isn't already pinned (bulk "Pin" for multi-select). */ pinTasks: (taskIds: string[]) => void; + /** Unpin every id that is currently pinned (bulk "Unpin" for multi-select). */ + unpinTasks: (taskIds: string[]) => void; setSidebarTaskOrder: (orderedTaskIds: string[]) => void; /** Replace the stored subtask order for a parent task. Empty array clears it. */ setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index 67dfd6cb37..bb133a48c5 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -32,6 +32,8 @@ const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views"; const SIDEBAR_ACTIVE_VIEW_KEY = "kandev.sidebar.activeViewId"; const SIDEBAR_DRAFT_KEY = "kandev.sidebar.draft"; const BACKEND_DOWN = "backend down"; +const PINNED_KEY = "kandev.sidebar.pinnedTaskIds"; +const ORDER_KEY = "kandev.sidebar.orderedTaskIds"; function makeSidebarView(id: string, name: string) { return { @@ -85,9 +87,6 @@ describe("toggleSubtaskCollapsed", () => { }); describe("sidebar task prefs (pin + manual order)", () => { - const PINNED_KEY = "kandev.sidebar.pinnedTaskIds"; - const ORDER_KEY = "kandev.sidebar.orderedTaskIds"; - beforeEach(() => { window.localStorage.clear(); vi.mocked(updateUserSettings).mockResolvedValue({ @@ -131,6 +130,16 @@ describe("sidebar task prefs (pin + manual order)", () => { ]); }); + it("unpinTasks removes all given ids and leaves other pinned tasks alone", () => { + const store = makeStore(); + store.getState().pinTasks(["t1", "t2", "t3"]); + + store.getState().unpinTasks(["t1", "t3"]); + + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]); + }); + it("setSidebarTaskOrder replaces and persists", () => { const store = makeStore(); store.getState().setSidebarTaskOrder(["a", "b", "c"]); @@ -169,6 +178,15 @@ describe("sidebar task prefs (pin + manual order)", () => { expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]); expect(window.localStorage.getItem(PINNED_KEY)).toBe(before); }); +}); + +describe("sidebar task prefs sync", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + }); it("records sync errors and clears them after a later successful sync", async () => { const store = makeStore(); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index f0b82f7548..ee8cb2a47c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -527,6 +527,7 @@ export type AppState = { setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"]; togglePinnedTask: UIA["togglePinnedTask"]; pinTasks: UIA["pinTasks"]; + unpinTasks: UIA["unpinTasks"]; setSidebarTaskOrder: UIA["setSidebarTaskOrder"]; setSubtaskOrder: UIA["setSubtaskOrder"]; removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"]; From 7532efeffd00c0177ad6b1151dca983b839cf7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 6 Jul 2026 16:22:42 +0100 Subject: [PATCH 18/19] refactor: keep sidebar task components under limits --- .../task/task-switcher-color-menu.tsx | 72 +++++++++++++++++++ .../task/task-switcher-context-menu.tsx | 65 +---------------- .../components/task/task-switcher-group.tsx | 49 +++++++++++++ apps/web/components/task/task-switcher.tsx | 50 +------------ 4 files changed, 123 insertions(+), 113 deletions(-) create mode 100644 apps/web/components/task/task-switcher-color-menu.tsx create mode 100644 apps/web/components/task/task-switcher-group.tsx diff --git a/apps/web/components/task/task-switcher-color-menu.tsx b/apps/web/components/task/task-switcher-color-menu.tsx new file mode 100644 index 0000000000..aa22dd4cd2 --- /dev/null +++ b/apps/web/components/task/task-switcher-color-menu.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { IconCheck, IconPalette } from "@tabler/icons-react"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@kandev/ui/context-menu"; +import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color"; +import { + TASK_COLORS, + TASK_COLOR_BAR_CLASS, + TASK_COLOR_LABEL, + type TaskColor, +} from "@/lib/task-colors"; +import { cn } from "@/lib/utils"; + +export function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) { + const currentColor = useTaskColor(taskId); + const setColor = useSetTaskColor(); + return ( + + + + Color + {currentColor && ( + + )} + + + {TASK_COLORS.map((color) => ( + setColor(taskId, color)} + /> + ))} + + setColor(taskId, null)}> + + None + + + + ); +} + +function TaskColorMenuItem({ + color, + selected, + onSelect, +}: { + color: TaskColor; + selected: boolean; + onSelect: () => void; +}) { + return ( + + + {TASK_COLOR_LABEL[color]} + {selected && } + + ); +} diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 6108b8ef91..3cb8c97f71 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -3,13 +3,11 @@ import { cloneElement, isValidElement, useState } from "react"; import { IconArchive, - IconCheck, IconCopy, IconCircleDot, IconGitPullRequest, IconLink, IconLoader, - IconPalette, IconPencil, IconPin, IconPinFilled, @@ -30,14 +28,7 @@ import { type TaskMoveWorkflow, } from "@/components/task/task-move-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; -import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color"; -import { - TASK_COLORS, - TASK_COLOR_BAR_CLASS, - TASK_COLOR_LABEL, - type TaskColor, -} from "@/lib/task-colors"; -import { cn } from "@/lib/utils"; +import { TaskColorMenu } from "./task-switcher-color-menu"; import type { TaskSwitcherItem } from "./task-switcher"; export type StepDef = { @@ -571,57 +562,3 @@ function TaskLinkMenu({ ); } - -function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) { - const currentColor = useTaskColor(taskId); - const setColor = useSetTaskColor(); - return ( - - - - Color - {currentColor && ( - - )} - - - {TASK_COLORS.map((color) => ( - setColor(taskId, color)} - /> - ))} - - setColor(taskId, null)}> - - None - - - - ); -} - -function TaskColorMenuItem({ - color, - selected, - onSelect, -}: { - color: TaskColor; - selected: boolean; - onSelect: () => void; -}) { - return ( - - - {TASK_COLOR_LABEL[color]} - {selected && } - - ); -} diff --git a/apps/web/components/task/task-switcher-group.tsx b/apps/web/components/task/task-switcher-group.tsx new file mode 100644 index 0000000000..a316c6f06e --- /dev/null +++ b/apps/web/components/task/task-switcher-group.tsx @@ -0,0 +1,49 @@ +import { IconChevronDown } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +export function TaskSwitcherSkeleton() { + return ( +
+
+
+
+
+
+ ); +} + +export function GroupHeader({ + label, + groupKey, + count, + isCollapsed, + onToggle, +}: { + label: string; + groupKey: string; + count: number; + isCollapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index d890bfea97..86e5bad10f 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -1,9 +1,7 @@ "use client"; import { memo, useMemo } from "react"; -import { IconChevronDown } from "@tabler/icons-react"; 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 { @@ -13,6 +11,7 @@ import { } 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; @@ -124,53 +123,6 @@ type SubtaskToggleInfo = { 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; From 06869aabb503a07f50ed034dd865550fa4eeb4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 6 Jul 2026 16:33:15 +0100 Subject: [PATCH 19/19] test: restore sidebar selection console spy safely --- .../task-session-sidebar-selection.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/components/task/task-session-sidebar-selection.test.ts b/apps/web/components/task/task-session-sidebar-selection.test.ts index 1e23fdb265..edf9fabe22 100644 --- a/apps/web/components/task/task-session-sidebar-selection.test.ts +++ b/apps/web/components/task/task-session-sidebar-selection.test.ts @@ -42,13 +42,15 @@ describe("useBulkConfirmDialog", () => { it("clears the dialog even when archiving rejects", async () => { const bulkArchive = vi.fn().mockRejectedValue(new Error("boom")); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); - - act(() => result.current.open(["a"])); - await act(async () => { - await result.current.confirm({ cascade: false }); - }); - expect(result.current.state).toBeNull(); - errorSpy.mockRestore(); + try { + const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive)); + act(() => result.current.open(["a"])); + await act(async () => { + await result.current.confirm({ cascade: false }); + }); + expect(result.current.state).toBeNull(); + } finally { + errorSpy.mockRestore(); + } }); });