From 5105ac224c9bb99dea58f111f22c7050c12b09ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:16:14 +0100 Subject: [PATCH] feat: cmd+click and shift+click multi-select for kanban and sidebar (#1518) * feat: add anchored range selection to multi-select reducer * feat: cmd+click and shift+click multi-select on kanban board * feat: add cmd/shift multi-select to sidebar task list * test: e2e coverage for cmd/shift multi-select * fix: harden multi-select selection lifecycle from review feedback * test: scope multi-select e2e helpers to active containers * fix: add sidebar multi-select hook tests and tighten bulk-action lifecycle * fix: prune hidden sidebar selections and order bulk moves by view * test: cover visible-order bulk move and anchor realignment * fix: harden sidebar bulk-move scope and keyboard selection parity * fix: order kanban bulk moves by board position * test: cover multi-select helpers and fix stale-id sort fallback * fix: order kanban card context-menu moves by board position * feat: sidebar multi-select shows only bulk-valid actions with bulk pin and delete * fix: harden multi-select bulk ordering, selection clearing, and a11y * fix: gate kanban card move-to-step for mixed-workflow selections * fix: unpin fully pinned sidebar selections * refactor: keep sidebar task components under limits * test: restore sidebar selection console spy safely --- apps/web/components/kanban-board.tsx | 1 + apps/web/components/kanban-card-click.test.ts | 64 ++++ apps/web/components/kanban-card-content.tsx | 2 +- apps/web/components/kanban-card.tsx | 82 ++++- 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/components/task/task-item.tsx | 71 +++- .../task-session-sidebar-archived-item.ts | 36 +++ .../task-session-sidebar-selection.test.ts | 56 ++++ .../task/task-session-sidebar-selection.tsx | 231 +++++++++++++ .../components/task/task-session-sidebar.tsx | 51 +-- .../task/task-switcher-click.test.ts | 91 ++++++ .../task/task-switcher-color-menu.tsx | 72 +++++ .../task/task-switcher-context-menu.tsx | 306 +++++++++++++----- .../components/task/task-switcher-group.tsx | 49 +++ .../components/task/task-switcher.test.tsx | 29 +- apps/web/components/task/task-switcher.tsx | 184 ++++++++--- apps/web/e2e/pages/kanban-page.ts | 31 ++ apps/web/e2e/pages/sidebar-tasks-page.ts | 94 ++++++ .../kanban/cmd-shift-multi-select.spec.ts | 201 ++++++++++++ .../tests/task/sidebar-multi-select.spec.ts | 289 +++++++++++++++++ .../hooks/use-sidebar-multi-select.test.ts | 199 ++++++++++++ apps/web/hooks/use-sidebar-multi-select.ts | 185 +++++++++++ .../hooks/use-task-multi-select-range.test.ts | 100 ++++++ apps/web/hooks/use-task-multi-select.test.ts | 1 + apps/web/hooks/use-task-multi-select.ts | 113 ++++++- apps/web/lib/kanban/task-order.test.ts | 26 +- apps/web/lib/kanban/task-order.ts | 15 + apps/web/lib/kanban/view-registry.ts | 1 + .../lib/sidebar/apply-view-flatten.test.ts | 93 ++++++ apps/web/lib/sidebar/apply-view.ts | 68 ++++ .../slices/ui/sidebar-task-prefs-actions.ts | 33 ++ apps/web/lib/state/slices/ui/types.ts | 4 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 38 ++- apps/web/lib/state/store.ts | 2 + 37 files changed, 2682 insertions(+), 234 deletions(-) create mode 100644 apps/web/components/kanban-card-click.test.ts create mode 100644 apps/web/components/task/task-session-sidebar-archived-item.ts create mode 100644 apps/web/components/task/task-session-sidebar-selection.test.ts create mode 100644 apps/web/components/task/task-session-sidebar-selection.tsx create mode 100644 apps/web/components/task/task-switcher-click.test.ts create mode 100644 apps/web/components/task/task-switcher-color-menu.tsx create mode 100644 apps/web/components/task/task-switcher-group.tsx 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 create mode 100644 apps/web/hooks/use-sidebar-multi-select.test.ts create mode 100644 apps/web/hooks/use-sidebar-multi-select.ts create mode 100644 apps/web/hooks/use-task-multi-select-range.test.ts create mode 100644 apps/web/lib/sidebar/apply-view-flatten.test.ts 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-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-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..69b2823388 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"; @@ -82,6 +83,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; } @@ -111,6 +114,7 @@ function useKanbanCardMenus({ >) { const moveTargets = useKanbanCardMoveTargets(task.id, steps); const moveTasks = useTaskWorkflowMove(); + const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); const [showPRDialog, setShowPRDialog] = useState(false); @@ -134,13 +138,22 @@ function useKanbanCardMenus({ }; 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); + // 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); return; } if (!moveTargets.currentWorkflowId) return; - runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId); + runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId); }; const menuBase = { @@ -168,9 +181,11 @@ 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(selectedTaskIds, workflowId, stepId); + runMoveTasks(orderedSelectedIds(), workflowId, stepId); }, }), showDeleteConfirm, @@ -251,6 +266,42 @@ 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. + */ +/** @internal Exported for unit testing the four-branch click dispatch. */ +export 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 { + // 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); + return; + } + if (e.shiftKey && handlers.onRangeSelect) { + e.preventDefault(); + handlers.onRangeSelect(taskId); + return; + } + if (handlers.isMultiSelectMode && handlers.onToggleSelect) { + handlers.onToggleSelect(taskId); + return; + } + handlers.onClick?.(task); +} + function useActiveWorkspaceRepositories() { const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); return useAppStore((state) => @@ -274,6 +325,7 @@ export function KanbanCard({ isSelected, selectedIds, onToggleSelect, + onRangeSelect, isMultiSelectMode, }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ @@ -306,18 +358,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 +385,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/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 11e9f54bd4..2307bba8dc 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 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 | React.KeyboardEvent) => void; diffStats?: DiffStats; isRemoteExecutor?: boolean; remoteExecutorType?: string; @@ -102,10 +110,53 @@ 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?.(); +} + +/** 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, + 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", + // 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", + ); +} + +/** Mouse uses the modifier-aware `onSelect`; falls back to the plain `onClick`. */ +function taskItemRowClick( + onSelect: ((e: React.MouseEvent | React.KeyboardEvent) => void) | undefined, + onClick: (() => void) | undefined, +): (e: React.MouseEvent) => void { + return (e) => (onSelect ? onSelect(e) : onClick?.()); } function TaskStateIcon({ @@ -347,7 +398,9 @@ export const TaskItem = memo(function TaskItem({ sessionState, isArchived, isSelected = false, + isMultiSelected = false, onClick, + onSelect, diffStats, isRemoteExecutor, remoteExecutorType, @@ -382,17 +435,11 @@ export const TaskItem = memo(function TaskItem({ role="button" tabIndex={0} data-testid="sidebar-task-item" - data-active={isSelected ? "true" : "false"} - aria-current={isSelected ? "true" : undefined} - onClick={onClick} - onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)} + {...taskItemStateAttrs(isSelected, isMultiSelected)} + onClick={taskItemRowClick(onSelect, onClick)} + onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, 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-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.test.ts b/apps/web/components/task/task-session-sidebar-selection.test.ts new file mode 100644 index 0000000000..edf9fabe22 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-selection.test.ts @@ -0,0 +1,56 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useBulkConfirmDialog } from "./task-session-sidebar-selection"; + +const tasks = [ + { id: "a", remoteExecutorType: "docker" }, + { id: "b", remoteExecutorType: null }, + { id: "c", remoteExecutorType: "sprites" }, +]; + +describe("useBulkConfirmDialog", () => { + it("open() captures the ids and their executor types", () => { + const bulkArchive = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useBulkConfirmDialog(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(() => useBulkConfirmDialog(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(() => useBulkConfirmDialog(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(() => {}); + 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(); + } + }); +}); 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..450ef37b87 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-selection.tsx @@ -0,0 +1,231 @@ +"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, + flattenVisibleTaskIds, + sortIdsByVisibleOrder, + 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 BulkConfirmState = { ids: string[]; executorTypes: Array }; + +/** + * 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 useBulkConfirmDialog( + displayTasks: Array<{ id: string; remoteExecutorType?: string | null }>, + run: (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 run(state.ids, { cascade }); + } catch (error) { + console.error("Bulk action failed:", error); + } finally { + setState(null); + } + }, + [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; + unpinTasks: (ids: string[]) => void; + pinnedTaskIds: string[]; + visibleTaskIds: string[]; + movableSelectedIds: Set; +}) { + const { multiSelect, pinTasks, unpinTasks, pinnedTaskIds, 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[]) => { + 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. + if (allPinned) unpinTasks(orderedIds); + else pinTasks(orderedIds); + clearSelection(); + }, + [pinTasks, unpinTasks, pinnedTaskIds, clearSelection, visibleTaskIds], + ); + + 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/delete confirm dialogs. Returns the props bundle for + * `TaskSwitcher` plus the dialog state the panel renders via `SidebarBulkDialogs`. + */ +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, 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); + + const visibleTaskIds = useMemo( + () => flattenVisibleTaskIds(grouped, collapsedGroups, collapsedSubtaskParents), + [grouped, collapsedGroups, collapsedSubtaskParents], + ); + + // 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. + const { isMixedWorkflowSelection, movableSelectedIds } = useMemo( + () => computeMixedWorkflowSelection(displayTasks, selectedIds), + [displayTasks, selectedIds], + ); + + const { onSelectTaskRange, onBulkMove, onBulkPin } = useSelectionHandlers({ + multiSelect, + pinTasks, + unpinTasks, + pinnedTaskIds, + visibleTaskIds, + movableSelectedIds, + }); + + const switcherProps = { + selectedTaskIds: selectedIds, + onToggleSelectTask: toggleSelect, + onSelectTaskRange, + onBulkArchive: archiveDialog.open, + onBulkDelete: deleteDialog.open, + onBulkPin, + onBulkMove, + onClearSelection: clearSelection, + isMixedWorkflowSelection, + }; + + return { + switcherProps, + archiveDialog, + deleteDialog, + isArchiving: multiSelect.isArchiving, + isDeleting: multiSelect.isDeleting, + }; +} + +export function SidebarBulkDialogs({ + selection, +}: { + selection: ReturnType; +}) { + const { archiveDialog, deleteDialog } = selection; + return ( + <> + {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 7ab4916d31..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"; @@ -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, 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"; @@ -27,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 { @@ -160,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); @@ -262,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; }, [ @@ -629,6 +597,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 +636,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-click.test.ts b/apps/web/components/task/task-switcher-click.test.ts new file mode 100644 index 0000000000..420c41f544 --- /dev/null +++ b/apps/web/components/task/task-switcher-click.test.ts @@ -0,0 +1,91 @@ +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 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(), + 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(); + }); + + // 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/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 7dea9afcf1..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 = { @@ -61,7 +52,17 @@ 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; + 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"). */ + isMixedWorkflowSelection?: boolean; }; export function TaskItemWithContextMenu({ @@ -78,7 +79,15 @@ export function TaskItemWithContextMenu({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, isDeleting, + selectedTaskIds, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + isMixedWorkflowSelection, }: ContextMenuProps) { const [contextOpen, setContextOpen] = useState(false); const [menuKey, setMenuKey] = useState(0); @@ -108,7 +117,15 @@ export function TaskItemWithContextMenu({ onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} isPinned={isPinned} + pinnedTaskIds={pinnedTaskIds} isDeleting={isDeleting} + selectedTaskIds={selectedTaskIds} + onBulkArchive={onBulkArchive} + onBulkDelete={onBulkDelete} + onBulkPin={onBulkPin} + onBulkMove={onBulkMove} + onClearSelection={onClearSelection} + isMixedWorkflowSelection={isMixedWorkflowSelection} closeMenu={closeMenu} moveTasks={moveTasks} /> @@ -135,24 +152,78 @@ function TaskContextMenuItems({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, isDeleting, + selectedTaskIds, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + isMixedWorkflowSelection, closeMenu, moveTasks, }: TaskContextMenuItemsProps) { + // 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]; + + // 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 ( + + ); + } + + // 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(); + handler(id); + } + : handler; + const onDelete = withClear(onDeleteTask); return ( <> Duplicate - + - + + + ); +} + +/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */ +function BulkSelectionMenuItems({ + task, + actingIds, + workflows, + stepsByWorkflowId, + steps, + isMixedWorkflowSelection, + pinnedTaskIds, + onBulkPin, + onBulkArchive, + onBulkDelete, + onBulkMove, + closeMenu, + moveTasks, +}: { + task: TaskSwitcherItem; + actingIds: string[]; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record; + steps?: StepDef[]; + isMixedWorkflowSelection?: boolean; + pinnedTaskIds?: string[]; + 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; + 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)}> + {allPinned ? ( + + ) : ( + + )} + {pinLabel} + + )} + + + {onBulkDelete && ( + <> + + onBulkDelete(actingIds)}> + + Delete {n} tasks + + + )} ); } @@ -234,13 +388,30 @@ function TaskRenameItem({ function TaskArchiveItem({ taskId, + actingIds, + actingOnSelection, disabled, onArchiveTask, + onBulkArchive, }: { taskId: string; + actingIds: string[]; + actingOnSelection: boolean; disabled?: boolean; onArchiveTask?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; }) { + // 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)}> + + {n > 1 ? `Archive ${n} tasks` : "Archive"} + + ); + } if (!onArchiveTask) return null; return ( onArchiveTask(taskId)}> @@ -257,21 +428,62 @@ function TaskMoveItems({ steps, isDeleting, onMoveToStep, + actingIds, + actingOnSelection, + onBulkMove, + isMixedWorkflowSelection, closeMenu, moveTasks, -}: Omit) { +}: Omit & { + actingIds: string[]; + actingOnSelection: boolean; +}) { if (!task.workflowId) return null; + const workflowId = task.workflowId; + // 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. + }); + }; + + // 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 (actingOnSelection) { + moveToStep = isMixedWorkflowSelection + ? undefined + : (stepId) => runSelectionMove(workflowId, stepId); + } else { + moveToStep = selectMoveAction(task.id, workflowId, onMoveToStep, closeMenu); + } + return ( { + onMoveToStep={moveToStep} + onSendToWorkflow={(targetWorkflowId, stepId) => { + if (actingOnSelection) { + runSelectionMove(targetWorkflowId, stepId); + return; + } closeMenu(); - void moveTasks([task.id], workflowId, stepId).catch(() => { + void moveTasks([task.id], targetWorkflowId, stepId).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }} @@ -350,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.test.tsx b/apps/web/components/task/task-switcher.test.tsx index 5978310145..577171ff5b 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; @@ -23,6 +23,8 @@ function item(id: string, parentTaskId?: string): TaskSwitcherItem { const ROOT = item("Root"); const CHILD = item("Child", "Root"); const GRANDCHILD = item("Grandchild", "Child"); +const TASK_A = item("Task A"); +const TASK_B = item("Task B"); function grouped(): GroupedSidebarList { return { @@ -136,3 +138,28 @@ describe("TaskSwitcher — nested subtasks beyond depth 1", () => { } }); }); + +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 e4052f9a6c..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; @@ -69,61 +68,61 @@ 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; + onBulkDelete?: (taskIds: string[]) => void; + onBulkPin?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => 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. + */ +/** @internal Exported for unit testing the modifier-aware click dispatch. */ +export function dispatchSidebarRowClick( + e: React.MouseEvent | React.KeyboardEvent, + taskId: string, + isSelecting: boolean, + handlers: { + onSelectTask: (taskId: string) => void; + onToggleSelectTask?: (taskId: string) => void; + onSelectTaskRange?: (taskId: string) => void; + }, +): void { + // Only intercept a modifier click when the matching handler is wired (the + // mobile switcher renders without selection handlers — there a Cmd/Shift click + // must still navigate rather than become a no-op). + if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelectTask) { + e.preventDefault(); + handlers.onToggleSelectTask(taskId); + return; + } + if (e.shiftKey && handlers.onSelectTaskRange) { + e.preventDefault(); + handlers.onSelectTaskRange(taskId); + return; + } + if (isSelecting && handlers.onToggleSelectTask) { + handlers.onToggleSelectTask(taskId); + return; + } + handlers.onSelectTask(taskId); +} + type SubtaskToggleInfo = { subtaskCount: number; subtasksCollapsed: boolean; onToggleSubtasks: () => void; }; -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; @@ -142,7 +141,17 @@ 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; + onSelectTaskRange?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; + onBulkDelete?: (taskIds: string[]) => void; + onBulkPin?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; + isMixedWorkflowSelection?: boolean; }; function TaskRow({ @@ -163,9 +172,21 @@ function TaskRow({ onMoveToStep, onTogglePin, isPinned, + pinnedTaskIds, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + 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} @@ -341,8 +378,18 @@ 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; + onToggleSelectTask?: (taskId: string) => void; + onSelectTaskRange?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; + onBulkDelete?: (taskIds: string[]) => void; + onBulkPin?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + onClearSelection?: () => void; + isMixedWorkflowSelection?: boolean; }; function GroupSection({ @@ -367,8 +414,18 @@ function GroupSection({ onTogglePin, onReorderGroup, onReorderSubtasks, + pinnedTaskIds, pinnedSet, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + isMixedWorkflowSelection, }: GroupSectionProps) { const totalCount = countGroupTasks(group.tasks, subTasksByParentId); const ctx: TaskTreeContext = { @@ -389,7 +446,17 @@ function GroupSection({ onLinkIssue, onMoveToStep, onTogglePin, + pinnedTaskIds, deletingTaskId, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + isMixedWorkflowSelection, }, onReorderGroup, onReorderSubtasks, @@ -437,6 +504,15 @@ export const TaskSwitcher = memo(function TaskSwitcher({ deletingTaskId, isLoading = false, totalTaskCount, + selectedTaskIds, + onToggleSelectTask, + onSelectTaskRange, + onBulkArchive, + onBulkDelete, + onBulkPin, + onBulkMove, + onClearSelection, + isMixedWorkflowSelection, }: TaskSwitcherProps) { const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); if (isLoading) return ; @@ -476,8 +552,18 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onTogglePin={onTogglePin} onReorderGroup={onReorderGroup} onReorderSubtasks={onReorderSubtasks} + pinnedTaskIds={pinnedTaskIds} pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} + selectedTaskIds={selectedTaskIds} + onToggleSelectTask={onToggleSelectTask} + onSelectTaskRange={onSelectTaskRange} + onBulkArchive={onBulkArchive} + onBulkDelete={onBulkDelete} + onBulkPin={onBulkPin} + onBulkMove={onBulkMove} + onClearSelection={onClearSelection} + isMixedWorkflowSelection={isMixedWorkflowSelection} /> ))}
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..9f2d43e7f7 --- /dev/null +++ b/apps/web/e2e/pages/sidebar-tasks-page.ts @@ -0,0 +1,94 @@ +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. + */ +export class SidebarTasksPage { + readonly root: Locator; + + constructor(private readonly page: Page) { + // 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 { + // 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 { + 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} ${plural(count)}` }); + } + + singleArchiveMenuItem(): Locator { + return this.page.getByRole("menuitem", { name: "Archive", exact: true }); + } + + bulkArchiveConfirm(): Locator { + return this.page.getByTestId("sidebar-bulk-archive-confirm"); + } + + bulkPinMenuItem(count: number): Locator { + 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)}` }); + } + + 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/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..fece304b7e --- /dev/null +++ b/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts @@ -0,0 +1,201 @@ +/** + * 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"; + +// 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 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-", "")), + ); +} + +/** Task ids of the selected (primary-ring) cards in a column. */ +async function columnSelectedIds(page: Page, stepId: string): Promise { + return activeColumn(page, 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 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); + + 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, + }) => { + 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.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 new file mode 100644 index 0000000000..cfc24d1eab --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-multi-select.spec.ts @@ -0,0 +1,289 @@ +/** + * 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") + .first() + .locator("[data-testid='sortable-task-block']") + .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("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 }); + + 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 ({ + 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"); + }); +}); 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..def0d8e13c --- /dev/null +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -0,0 +1,199 @@ +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, 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, activeSessionId: null } }), + }), +})); +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); + deleteTaskById.mockReset().mockResolvedValue(undefined); + archiveAndSwitch.mockReset().mockResolvedValue(undefined); + removeTaskFromBoard.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("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("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" }, + }); + act(() => result.current.toggleSelect("a")); + expect(result.current.selectedIds.size).toBe(1); + + 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 () => { + 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("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")); + 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 new file mode 100644 index 0000000000..421e223c41 --- /dev/null +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -0,0 +1,185 @@ +"use client"; + +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 }; + +/** + * 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. + */ +function useSidebarBulkActions( + dispatch: Dispatch<{ type: "set_selected"; ids: Set }>, + clearSelection: () => void, +) { + const store = useAppStoreApi(); + 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 runBulkRemoval = useCallback( + async ( + ids: string[], + per: (id: string) => Promise, + handleActive: (id: string) => Promise, + setBusy: (v: boolean) => void, + noun: string, + ) => { + if (ids.length === 0) return; + const activeId = store.getState().tasks.activeTaskId; + const activeInSet = activeId != null && ids.includes(activeId); + const restIds = activeInSet ? ids.filter((id) => id !== activeId) : ids; + setBusy(true); + try { + 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 handleActive(activeId!); + } catch { + failed.push(activeId!); + } + } + if (failed.length > 0) { + // 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 ${noun} ${failed.length} task${failed.length === 1 ? "" : "s"}`, + variant: "error", + }); + return; + } + clearSelection(); + } finally { + setBusy(false); + } + }, + [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( + async (ids: string[], targetWorkflowId: string, targetStepId: string) => { + if (ids.length === 0) return; + try { + await moveTasks(ids, targetWorkflowId, targetStepId); + 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], + ); + + 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: bulk.isArchiving, + isDeleting: bulk.isDeleting, + toggleSelect, + selectRange, + clearSelection, + pruneToVisible, + bulkArchive: bulk.bulkArchive, + bulkDelete: bulk.bulkDelete, + bulkMove: bulk.bulkMove, + }; +} 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..9e418d53c7 --- /dev/null +++ b/apps/web/hooks/use-task-multi-select-range.test.ts @@ -0,0 +1,100 @@ +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("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", + taskId: "c", + orderedIds: ordered, + }); + expect(next.selectedIds).toEqual(new Set(["z", "c"])); + 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.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..b23ed47c6b 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -4,8 +4,10 @@ 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 { sortIdsByCreatedDesc } from "@/lib/kanban/task-order"; -function useTaskMultiSelectStore() { +/** @internal Exported for reuse by the sidebar multi-select hook. */ +export function useTaskMultiSelectStore() { const store = useAppStoreApi(); const removeTasksFromStore = useCallback( @@ -77,7 +79,23 @@ 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 sortIdsByCreatedDesc(ids, taskById); + }, + [store], + ); + + return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder }; } function useBulkOperations({ @@ -93,6 +111,7 @@ function useBulkOperations({ removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, + sortByDisplayOrder, }: { workflowId: string | null; selectedIdsRef: RefObject>; @@ -106,6 +125,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 ( @@ -143,7 +163,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) => { @@ -159,7 +181,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 }; @@ -170,11 +199,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 +221,46 @@ export const INITIAL_STATE: MultiSelectState = { isMultiSelectEnabled: false, isDeleting: false, isArchiving: false, + 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 + * 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, + 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, @@ -198,12 +271,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 }; + 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 }; + // 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": @@ -244,7 +331,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( @@ -252,6 +339,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], @@ -284,6 +377,7 @@ export function useTaskMultiSelect(workflowId: string | null) { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, + sortByDisplayOrder, }); return { @@ -293,6 +387,7 @@ export function useTaskMultiSelect(workflowId: string | null) { enableMultiSelect, toggleMultiSelect, toggleSelect, + selectRange, clearSelection, bulkDelete, bulkArchive, diff --git a/apps/web/lib/kanban/task-order.test.ts b/apps/web/lib/kanban/task-order.test.ts index d3c2908aa7..06bc95b4d7 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,27 @@ 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("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 fda5475a42..470924d930 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[] { + // 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) ?? {}), + ); +} 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; }; 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..910408d925 --- /dev/null +++ b/apps/web/lib/sidebar/apply-view-flatten.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import { + computeMixedWorkflowSelection, + flattenVisibleTaskIds, + sortIdsByVisibleOrder, + 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"]); + }); +}); + +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 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 e7fa54da2c..61430e09a9 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -458,6 +458,74 @@ 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; +} + +/** + * 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 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) ?? 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( tasks: TaskSwitcherItem[], view: SidebarView, 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..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 @@ -63,6 +63,39 @@ 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); + }, + // 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 eb30e9929b..8500899899 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -234,6 +234,10 @@ 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; + /** 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 bcd348d3ff..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({ @@ -117,6 +116,30 @@ 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("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"]); @@ -155,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 e8f59bd71a..ee8cb2a47c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -526,6 +526,8 @@ export type AppState = { migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"]; setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"]; togglePinnedTask: UIA["togglePinnedTask"]; + pinTasks: UIA["pinTasks"]; + unpinTasks: UIA["unpinTasks"]; setSidebarTaskOrder: UIA["setSidebarTaskOrder"]; setSubtaskOrder: UIA["setSubtaskOrder"]; removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"];