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
This commit is contained in:
José Almeida
2026-07-06 17:16:14 +01:00
committed by GitHub
parent 26773b28d1
commit 5105ac224c
37 changed files with 2682 additions and 234 deletions
+1
View File
@@ -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}
/>
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";
import { dispatchKanbanCardClick, type Task } from "./kanban-card";
function fakeEvent(mods: Partial<MouseEvent> = {}) {
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();
});
});
+1 -1
View File
@@ -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;
};
+66 -16
View File
@@ -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<string>;
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}
/>
</KanbanCardContextMenu>
+10
View File
@@ -34,6 +34,8 @@ interface KanbanColumnProps {
hideHeader?: boolean;
selectedIds?: Set<string>;
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 (
<div
ref={setNodeRef}
@@ -109,6 +116,9 @@ export function KanbanColumn({
isSelected={selectedIds?.has(task.id)}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onRangeSelect={
onSelectRange ? (taskId) => onSelectRange(taskId, columnTaskIds) : undefined
}
isMultiSelectMode={isMultiSelectMode}
/>
))}
@@ -48,6 +48,7 @@ export type SwimlaneContainerProps = {
selectedRepositoryIds?: string[];
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
onToggleMultiSelect?: () => void;
};
@@ -119,6 +120,7 @@ type WorkflowItemProps = {
showMaximizeButton?: boolean;
selectedIds?: Set<string>;
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}
/>
@@ -41,6 +41,7 @@ export type SwimlaneKanbanContentProps = {
showMaximizeButton?: boolean;
selectedIds?: Set<string>;
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<string>;
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}
/>
<MobileDropTargets steps={steps} currentStepId={currentStepId} isDragging={!!activeTask} />
@@ -260,6 +264,7 @@ function TabletKanbanLayout({
archivingTaskId,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
}: {
steps: WorkflowStep[];
@@ -275,6 +280,7 @@ function TabletKanbanLayout({
archivingTaskId?: string | null;
selectedIds?: Set<string>;
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}
/>
</div>
@@ -323,6 +330,7 @@ function DesktopKanbanLayout({
archivingTaskId,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
isCompactDesktop,
}: {
@@ -339,6 +347,7 @@ function DesktopKanbanLayout({
archivingTaskId?: string | null;
selectedIds?: Set<string>;
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,
],
);
@@ -22,9 +22,46 @@ type SwipeableColumnsProps = {
archivingTaskId?: string | null;
selectedIds?: Set<string>;
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<typeof useEmblaCarousel>[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
/>
+59 -12
View File
@@ -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<HTMLDivElement>, onClick?: () => void): void {
function handleTaskItemKeyDown(
e: React.KeyboardEvent<HTMLDivElement>,
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)}
>
<SelectionBar isSelected={isSelected} color={taskColor} />
<RowConnector depth={indent.depth} leftPx={indent.connectorLeftPx} />
@@ -0,0 +1,36 @@
import type { TaskSwitcherItem } from "./task-switcher";
import type { useArchivedTaskState } from "./task-archived-context";
export function buildArchivedSidebarItem(
s: ReturnType<typeof useArchivedTaskState>,
): 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,
};
}
@@ -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();
}
});
});
@@ -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<string | null | undefined> };
/**
* 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<void>,
) {
const [state, setState] = useState<BulkConfirmState | null>(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<typeof useSidebarMultiSelect>;
/**
* 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<string>;
}) {
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<typeof useSidebarSelection>;
}) {
const { archiveDialog, deleteDialog } = selection;
return (
<>
{archiveDialog.state && (
<TaskArchiveConfirmDialog
open
onOpenChange={(o) => !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 && (
<TaskDeleteConfirmDialog
open
onOpenChange={(o) => !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"
/>
)}
</>
);
}
@@ -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<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): 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}
/>
</PanelBody>
<SidebarDialogs actions={sidebarActions} repositories={repositories} />
<SidebarBulkDialogs selection={selection} />
</PanelRoot>
);
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { dispatchSidebarRowClick } from "./task-switcher";
function fakeEvent(mods: Partial<MouseEvent> = {}) {
return {
metaKey: false,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
...mods,
} as unknown as React.MouseEvent;
}
function fakeKeyEvent(mods: Partial<KeyboardEvent> = {}) {
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");
});
});
@@ -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 (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
<IconPalette className="mr-2 h-4 w-4" />
Color
{currentColor && (
<span
className={cn(
"ml-2 inline-block h-2 w-2 rounded-full",
TASK_COLOR_BAR_CLASS[currentColor],
)}
/>
)}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
{TASK_COLORS.map((color) => (
<TaskColorMenuItem
key={color}
color={color}
selected={currentColor === color}
onSelect={() => setColor(taskId, color)}
/>
))}
<ContextMenuSeparator />
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
None
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function TaskColorMenuItem({
color,
selected,
onSelect,
}: {
color: TaskColor;
selected: boolean;
onSelect: () => void;
}) {
return (
<ContextMenuItem onSelect={onSelect}>
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
{TASK_COLOR_LABEL[color]}
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
</ContextMenuItem>
);
}
@@ -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<string>;
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 (
<BulkSelectionMenuItems
task={task}
actingIds={actingIds}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
isMixedWorkflowSelection={isMixedWorkflowSelection}
pinnedTaskIds={pinnedTaskIds}
onBulkPin={onBulkPin}
onBulkArchive={onBulkArchive}
onBulkDelete={onBulkDelete}
onBulkMove={onBulkMove}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
);
}
// 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 (
<>
<TaskPinItem
taskId={task.id}
isPinned={isPinned}
disabled={isDeleting}
onTogglePin={onTogglePin}
onTogglePin={withClear(onTogglePin)}
/>
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
Duplicate
</ContextMenuItem>
<TaskArchiveItem taskId={task.id} disabled={isDeleting} onArchiveTask={onArchiveTask} />
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection={actingOnSelection}
disabled={isDeleting}
onArchiveTask={onArchiveTask}
onBulkArchive={onBulkArchive}
/>
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
<TaskLinkMenu
disabled={isDeleting}
@@ -166,10 +237,93 @@ function TaskContextMenuItems({
steps={steps}
isDeleting={isDeleting}
onMoveToStep={onMoveToStep}
actingIds={actingIds}
actingOnSelection={actingOnSelection}
onBulkMove={onBulkMove}
isMixedWorkflowSelection={isMixedWorkflowSelection}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDeleteTask} />
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDelete} />
</>
);
}
/** 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<string, StepDef[]>;
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<typeof useTaskWorkflowMove>;
}) {
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 && (
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
{allPinned ? (
<IconPinFilled className="mr-2 h-4 w-4" />
) : (
<IconPin className="mr-2 h-4 w-4" />
)}
{pinLabel}
</ContextMenuItem>
)}
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection
onArchiveTask={undefined}
onBulkArchive={onBulkArchive}
/>
<TaskMoveItems
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
onMoveToStep={undefined}
actingIds={actingIds}
actingOnSelection
onBulkMove={onBulkMove}
isMixedWorkflowSelection={isMixedWorkflowSelection}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
{onBulkDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
<IconTrash className="mr-2 h-4 w-4" />
Delete {n} tasks
</ContextMenuItem>
</>
)}
</>
);
}
@@ -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 (
<ContextMenuItem disabled={disabled} onSelect={() => onBulkArchive(actingIds)}>
<IconArchive className="mr-2 h-4 w-4" />
{n > 1 ? `Archive ${n} tasks` : "Archive"}
</ContextMenuItem>
);
}
if (!onArchiveTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onArchiveTask(taskId)}>
@@ -257,21 +428,62 @@ function TaskMoveItems({
steps,
isDeleting,
onMoveToStep,
actingIds,
actingOnSelection,
onBulkMove,
isMixedWorkflowSelection,
closeMenu,
moveTasks,
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask">) {
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
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 (
<TaskMoveContextMenuItems
currentWorkflowId={task.workflowId}
currentStepId={task.workflowStepId}
currentWorkflowId={workflowId}
// For a selection spanning several steps, don't disable the clicked row's
// step — the backend bulk move skips tasks already there, and the other
// selected rows still need it as a target.
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={selectMoveAction(task.id, task.workflowId, onMoveToStep, closeMenu)}
onSendToWorkflow={(workflowId, stepId) => {
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({
</ContextMenuSub>
);
}
function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) {
const currentColor = useTaskColor(taskId);
const setColor = useSetTaskColor();
return (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
<IconPalette className="mr-2 h-4 w-4" />
Color
{currentColor && (
<span
className={cn(
"ml-2 inline-block h-2 w-2 rounded-full",
TASK_COLOR_BAR_CLASS[currentColor],
)}
/>
)}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
{TASK_COLORS.map((color) => (
<TaskColorMenuItem
key={color}
color={color}
selected={currentColor === color}
onSelect={() => setColor(taskId, color)}
/>
))}
<ContextMenuSeparator />
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
None
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function TaskColorMenuItem({
color,
selected,
onSelect,
}: {
color: TaskColor;
selected: boolean;
onSelect: () => void;
}) {
return (
<ContextMenuItem onSelect={onSelect}>
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
{TASK_COLOR_LABEL[color]}
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
</ContextMenuItem>
);
}
@@ -0,0 +1,49 @@
import { IconChevronDown } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
export function TaskSwitcherSkeleton() {
return (
<div className="animate-pulse">
<div className="h-10 bg-foreground/5" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
</div>
);
}
export function GroupHeader({
label,
groupKey,
count,
isCollapsed,
onToggle,
}: {
label: string;
groupKey: string;
count: number;
isCollapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
data-testid="sidebar-group-header"
data-group-key={groupKey}
data-group-label={label}
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
>
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
{label}
</span>
<span className="text-[11px] text-muted-foreground/50">{count}</span>
<IconChevronDown
className={cn(
"h-3 w-3 text-muted-foreground/40 transition-transform",
isCollapsed && "-rotate-90",
)}
/>
</button>
);
}
@@ -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(
<Providers>
<TaskSwitcher
grouped={{
groups: [{ key: "__all__", label: "All", tasks: [TASK_A, TASK_B] }],
subTasksByParentId: new Map(),
}}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onBulkPin={vi.fn()}
pinnedTaskIds={[TASK_A.id, TASK_B.id]}
selectedTaskIds={new Set([TASK_A.id, TASK_B.id])}
/>
</Providers>,
);
fireEvent.contextMenu(screen.getByText(TASK_A.title));
expect(screen.getByText("Unpin 2 tasks")).toBeTruthy();
});
});
+135 -49
View File
@@ -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<string>;
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 (
<div className="animate-pulse">
<div className="h-10 bg-foreground/5" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
</div>
);
}
function GroupHeader({
label,
groupKey,
count,
isCollapsed,
onToggle,
}: {
label: string;
groupKey: string;
count: number;
isCollapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
data-testid="sidebar-group-header"
data-group-key={groupKey}
data-group-label={label}
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
>
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
{label}
</span>
<span className="text-[11px] text-muted-foreground/50">{count}</span>
<IconChevronDown
className={cn(
"h-3 w-3 text-muted-foreground/40 transition-transform",
isCollapsed && "-rotate-90",
)}
/>
</button>
);
}
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<string>;
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 (
<TaskItemWithContextMenu
@@ -181,9 +202,25 @@ function TaskRow({
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
isPinned={isPinned}
pinnedTaskIds={pinnedTaskIds}
isDeleting={deletingTaskId === task.id}
selectedTaskIds={selectedTaskIds}
onBulkArchive={onBulkArchive}
onBulkDelete={onBulkDelete}
onBulkPin={onBulkPin}
onBulkMove={onBulkMove}
onClearSelection={onClearSelection}
isMixedWorkflowSelection={isMixedWorkflowSelection}
>
<TaskItem
isMultiSelected={isMultiSelected}
onSelect={(e) =>
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<string>;
deletingTaskId?: string | null;
selectedTaskIds?: Set<string>;
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 <TaskSwitcherSkeleton />;
@@ -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}
/>
))}
</div>
+31
View File
@@ -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}`);
}
+94
View File
@@ -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<number> {
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 });
}
}
@@ -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<string[]> {
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<string[]> {
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");
});
});
@@ -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<Created[]> {
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<string[]> {
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<SidebarTasksPage> {
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");
});
});
@@ -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<typeof import("./use-task-multi-select")>();
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"]));
});
});
+185
View File
@@ -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<string> }>,
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<void>,
handleActive: (id: string) => Promise<void>,
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,
};
}
@@ -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<string>() });
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);
});
});
@@ -8,6 +8,7 @@ describe("multiSelectReducer", () => {
isMultiSelectEnabled: true,
isDeleting: true,
isArchiving: false,
anchorId: "a",
};
expect(multiSelectReducer(dirty, { type: "reset" })).toBe(INITIAL_STATE);
});
+104 -9
View File
@@ -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<string, { createdAt?: string }>();
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<Set<string>>;
@@ -106,6 +125,7 @@ function useBulkOperations({
removeTasksFromStore: (ids: Set<string>) => void;
applyMoveInStore: (ids: Set<string>, 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<string> }
| { 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>): 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,
+25 -1
View File
@@ -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<string, { createdAt?: string }>([
["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"]);
});
});
+15
View File
@@ -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, CreatedTask>): 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) ?? {}),
);
}
+1
View File
@@ -21,6 +21,7 @@ export type ViewContentProps = {
showMaximizeButton?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
};
@@ -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<string, TaskSwitcherItem[]> = {},
): 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"]));
});
});
+68
View File
@@ -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<string>,
): { isMixedWorkflowSelection: boolean; movableSelectedIds: Set<string> } {
const wfIds = new Set<string>();
const movable = new Set<string>();
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,
@@ -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;
+4
View File
@@ -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;
+35 -3
View File
@@ -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<ReturnType<typeof updateUserSettings>>);
});
it("records sync errors and clears them after a later successful sync", async () => {
const store = makeStore();
+2
View File
@@ -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"];