diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 2db37c6e9..31af79395 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -10,6 +10,10 @@ export const taskContextMenuInput = z.object({ hasEmptyCommandCenterCell: z.boolean().optional(), }); +export const bulkTaskContextMenuInput = z.object({ + taskCount: z.number().int().positive(), +}); + export const archivedTaskContextMenuInput = z.object({ taskTitle: z.string(), }); @@ -45,6 +49,10 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("external-app"), action: externalAppAction }), ]); +const bulkTaskAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("archive") }), +]); + const archivedTaskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("restore") }), z.object({ type: z.literal("delete") }), @@ -72,6 +80,9 @@ const splitDirection = z.enum(["left", "right", "up", "down"]); export const taskContextMenuOutput = z.object({ action: taskAction.nullable(), }); +export const bulkTaskContextMenuOutput = z.object({ + action: bulkTaskAction.nullable(), +}); export const archivedTaskContextMenuOutput = z.object({ action: archivedTaskAction.nullable(), }); @@ -87,6 +98,7 @@ export const splitContextMenuOutput = z.object({ }); export type TaskContextMenuInput = z.infer; +export type BulkTaskContextMenuInput = z.infer; export type ArchivedTaskContextMenuInput = z.infer< typeof archivedTaskContextMenuInput >; @@ -96,6 +108,7 @@ export type FileContextMenuInput = z.infer; export type ExternalAppAction = z.infer; export type TaskAction = z.infer; +export type BulkTaskAction = z.infer; export type ArchivedTaskAction = z.infer; export type FolderAction = z.infer; export type TabAction = z.infer; diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 24d3dbc62..93376654c 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -11,6 +11,8 @@ import type { ArchivedTaskAction, ArchivedTaskContextMenuInput, ArchivedTaskContextMenuResult, + BulkTaskAction, + BulkTaskContextMenuInput, ConfirmDeleteArchivedTaskInput, ConfirmDeleteArchivedTaskResult, ConfirmDeleteTaskInput, @@ -160,6 +162,27 @@ export class ContextMenuService { ]); } + async showBulkTaskContextMenu( + input: BulkTaskContextMenuInput, + ): Promise<{ action: BulkTaskAction | null }> { + const { taskCount } = input; + const label = `Archive ${taskCount} tasks`; + return this.showMenu([ + this.item( + label, + { type: "archive" }, + { + confirm: { + title: "Archive Tasks", + message: `Archive ${taskCount} tasks?`, + detail: "You can unarchive them later.", + confirmLabel: "Archive", + }, + }, + ), + ]); + } + async showArchivedTaskContextMenu( input: ArchivedTaskContextMenuInput, ): Promise { diff --git a/apps/code/src/main/trpc/routers/context-menu.ts b/apps/code/src/main/trpc/routers/context-menu.ts index 26d57bcf8..a394fcde3 100644 --- a/apps/code/src/main/trpc/routers/context-menu.ts +++ b/apps/code/src/main/trpc/routers/context-menu.ts @@ -3,6 +3,8 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { archivedTaskContextMenuInput, archivedTaskContextMenuOutput, + bulkTaskContextMenuInput, + bulkTaskContextMenuOutput, confirmDeleteArchivedTaskInput, confirmDeleteArchivedTaskOutput, confirmDeleteTaskInput, @@ -46,6 +48,11 @@ export const contextMenuRouter = router({ .output(taskContextMenuOutput) .mutation(({ input }) => getService().showTaskContextMenu(input)), + showBulkTaskContextMenu: publicProcedure + .input(bulkTaskContextMenuInput) + .output(bulkTaskContextMenuOutput) + .mutation(({ input }) => getService().showBulkTaskContextMenu(input)), + showArchivedTaskContextMenu: publicProcedure .input(archivedTaskContextMenuInput) .output(archivedTaskContextMenuOutput) diff --git a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx b/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx index 76e50fb7e..280f8c03b 100644 --- a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx +++ b/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx @@ -3,8 +3,16 @@ import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { Box } from "@radix-ui/themes"; import { useEffect } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; +import { useTaskSelectionStore } from "../stores/taskSelectionStore"; import { Sidebar, SidebarContent } from "./index"; +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + export function MainSidebar() { const { data: workspaces = {}, isFetched } = useWorkspaces(); const hasCompletedOnboarding = useOnboardingStore( @@ -19,6 +27,19 @@ export function MainSidebar() { } }, [isFetched, workspaces, hasCompletedOnboarding, setOpenAuto]); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (isEditableTarget(e.target)) return; + const { selectedTaskIds, clearSelection } = + useTaskSelectionStore.getState(); + if (selectedTaskIds.length === 0) return; + clearSelection(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + return ( diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx index 0a72aeeca..a3ed3fd1a 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -11,10 +11,11 @@ interface SidebarItemProps { label: React.ReactNode; subtitle?: React.ReactNode; isActive?: boolean; + isSelected?: boolean; isDimmed?: boolean; draggable?: boolean; onDragStart?: (e: React.DragEvent) => void; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; action?: SidebarItemAction; @@ -28,6 +29,7 @@ export function SidebarItem({ label, subtitle, isActive, + isSelected, draggable, onDragStart, onClick, @@ -69,9 +71,10 @@ export function SidebarItem({ type="button" className={cn( "group focus-visible:-outline-offset-2 flex w-full text-left text-[13px] leading-snug transition-colors focus-visible:outline-2 focus-visible:outline-accent-8", - "cursor-default disabled:opacity-100 data-active:bg-fill-selected", + "cursor-default disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)", )} data-active={isActive || undefined} + data-selected={(isSelected && !isActive) || undefined} draggable={draggable} onDragStart={onDragStart} style={{ diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 8edd66f80..28b711d4d 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -10,7 +10,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore" import { getSessionService } from "@features/sessions/service/service"; import { useSetupStore } from "@features/setup/stores/setupStore"; import { - archiveTaskImperative, + archiveTasksImperative, useArchiveTask, } from "@features/tasks/hooks/useArchiveTask"; import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks"; @@ -19,17 +19,19 @@ import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import type { Schemas } from "@renderer/api/generated"; +import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { useSidebarStore } from "../stores/sidebarStore"; +import { useTaskSelectionStore } from "../stores/taskSelectionStore"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; @@ -145,23 +147,133 @@ function SidebarMenuComponent() { navigateToSetup(); }; - const handleTaskClick = (taskId: string) => { + const queryClient = useQueryClient(); + + const selectedTaskIds = useTaskSelectionStore((s) => s.selectedTaskIds); + const toggleTaskSelection = useTaskSelectionStore( + (s) => s.toggleTaskSelection, + ); + const selectRange = useTaskSelectionStore((s) => s.selectRange); + const clearSelection = useTaskSelectionStore((s) => s.clearSelection); + const pruneSelection = useTaskSelectionStore((s) => s.pruneSelection); + + const organizeMode = useSidebarStore((s) => s.organizeMode); + const collapsedSections = useSidebarStore((s) => s.collapsedSections); + + const allSidebarTasks = useMemo( + () => [...sidebarData.pinnedTasks, ...sidebarData.flatTasks], + [sidebarData.pinnedTasks, sidebarData.flatTasks], + ); + + const allSidebarTaskIds = useMemo( + () => allSidebarTasks.map((t) => t.id), + [allSidebarTasks], + ); + + // Ordered list of currently visible task IDs in display order. Used as the + // index for shift-click range selection so it matches what the user sees — + // in by-project mode the chronological flat order would span across project + // groups and pull in unrelated tasks. + const orderedVisibleTaskIds = useMemo(() => { + const ids: string[] = sidebarData.pinnedTasks.map((t) => t.id); + if (organizeMode === "by-project") { + for (const group of sidebarData.groupedTasks) { + if (collapsedSections.has(group.id)) continue; + for (const t of group.tasks) ids.push(t.id); + } + } else { + for (const t of sidebarData.flatTasks) ids.push(t.id); + } + return ids; + }, [ + sidebarData.pinnedTasks, + sidebarData.flatTasks, + sidebarData.groupedTasks, + organizeMode, + collapsedSections, + ]); + + useEffect(() => { + pruneSelection(allSidebarTaskIds); + }, [allSidebarTaskIds, pruneSelection]); + + // The active (routed) task is implicitly part of any bulk selection — the + // user expects to see and act on it together with cmd/shift-clicked tasks. + const activeTaskId = sidebarData.activeTaskId; + const effectiveBulkIds = useMemo(() => { + if (selectedTaskIds.length === 0) return []; + if (!activeTaskId) return selectedTaskIds; + if (selectedTaskIds.includes(activeTaskId)) return selectedTaskIds; + return [activeTaskId, ...selectedTaskIds]; + }, [activeTaskId, selectedTaskIds]); + + const handleTaskClick = (taskId: string, e: React.MouseEvent) => { + if (e.shiftKey) { + e.preventDefault(); + selectRange(taskId, orderedVisibleTaskIds, activeTaskId); + return; + } + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggleTaskSelection(taskId); + return; + } + + clearSelection(); const task = taskMap.get(taskId); if (task) { navigateToTask(task); } }; - const allSidebarTasks = [ - ...sidebarData.pinnedTasks, - ...sidebarData.flatTasks, - ]; + const handleBulkContextMenu = useCallback( + async (e: React.MouseEvent, taskIds: string[]) => { + e.preventDefault(); + e.stopPropagation(); + try { + const result = + await trpcClient.contextMenu.showBulkTaskContextMenu.mutate({ + taskCount: taskIds.length, + }); + if (!result.action) return; + if (result.action.type === "archive") { + const { archived, failed } = await archiveTasksImperative( + taskIds, + queryClient, + ); + clearSelection(); + if (failed === 0) { + toast.success( + `${archived} ${archived === 1 ? "task" : "tasks"} archived`, + ); + } else { + toast.error(`${archived} archived, ${failed} failed`); + } + } + } catch (error) { + logger + .scope("sidebar-menu") + .error("Failed to show bulk context menu", error); + } + }, + [queryClient, clearSelection], + ); const handleTaskContextMenu = ( taskId: string, e: React.MouseEvent, isPinned: boolean, ) => { + // Bulk menu when 2+ tasks are in the effective selection (active + cmd/shift-clicked) + // and the right-clicked task is one of them. Otherwise clear and fall through. + if (effectiveBulkIds.length > 1) { + if (effectiveBulkIds.includes(taskId)) { + handleBulkContextMenu(e, effectiveBulkIds); + return; + } + clearSelection(); + } + const task = taskMap.get(taskId); if (task) { const workspace = workspaces[taskId]; @@ -201,7 +313,6 @@ function SidebarMenuComponent() { }; const updateTask = useUpdateTask(); - const queryClient = useQueryClient(); const handleArchivePrior = useCallback( async (taskId: string) => { @@ -209,10 +320,9 @@ function SidebarMenuComponent() { const clickedTask = allVisible.find((t) => t.id === taskId); if (!clickedTask) return; - const sortKey = "createdAt" as const; - const threshold = clickedTask[sortKey]; + const threshold = clickedTask.createdAt; const priorTaskIds = allVisible - .filter((t) => t.id !== taskId && t[sortKey] < threshold) + .filter((t) => t.id !== taskId && t.createdAt < threshold) .map((t) => t.id); if (priorTaskIds.length === 0) { @@ -220,33 +330,17 @@ function SidebarMenuComponent() { return; } - const nav = useNavigationStore.getState(); - const priorSet = new Set(priorTaskIds); - if ( - nav.view.type === "task-detail" && - nav.view.data && - priorSet.has(nav.view.data.id) - ) { - nav.navigateToTaskInput(); - } - - let done = 0; - let failed = 0; - for (const id of priorTaskIds) { - try { - await archiveTaskImperative(id, queryClient, { - skipNavigate: true, - }); - done++; - } catch { - failed++; - } - } + const { archived, failed } = await archiveTasksImperative( + priorTaskIds, + queryClient, + ); if (failed === 0) { - toast.success(`${done} ${done === 1 ? "task" : "tasks"} archived`); + toast.success( + `${archived} ${archived === 1 ? "task" : "tasks"} archived`, + ); } else { - toast.error(`${done} archived, ${failed} failed`); + toast.error(`${archived} archived, ${failed} failed`); } }, [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], @@ -371,6 +465,7 @@ function SidebarMenuComponent() { groupedTasks={sidebarData.groupedTasks} activeTaskId={sidebarData.activeTaskId} editingTaskId={editingTaskId} + selectedTaskIds={effectiveBulkIds} onTaskClick={handleTaskClick} onTaskDoubleClick={handleTaskDoubleClick} onTaskContextMenu={handleTaskContextMenu} diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 07960d98f..626106f25 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -38,7 +38,8 @@ interface TaskListViewProps { groupedTasks: TaskGroup[]; activeTaskId: string | null; editingTaskId: string | null; - onTaskClick: (taskId: string) => void; + selectedTaskIds: string[]; + onTaskClick: (taskId: string, e: React.MouseEvent) => void; onTaskDoubleClick: (taskId: string) => void; onTaskContextMenu: ( taskId: string, @@ -73,6 +74,8 @@ function SectionLabel({ function TaskRow({ task, isActive, + isSelected, + hideHoverActions, isEditing, onClick, onDoubleClick, @@ -86,8 +89,10 @@ function TaskRow({ }: { task: TaskData; isActive: boolean; + isSelected: boolean; + hideHoverActions: boolean; isEditing: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; onContextMenu: (e: React.MouseEvent, isPinned: boolean) => void; onArchive: () => void; @@ -109,6 +114,8 @@ function TaskRow({ taskId={task.id} label={task.title} isActive={isActive} + isSelected={isSelected} + hideHoverActions={hideHoverActions} isEditing={isEditing} workspaceMode={effectiveMode} worktreePath={workspace?.worktreePath ?? undefined} @@ -233,6 +240,7 @@ export function TaskListView({ groupedTasks, activeTaskId, editingTaskId, + selectedTaskIds, onTaskClick, onTaskDoubleClick, onTaskContextMenu, @@ -242,6 +250,11 @@ export function TaskListView({ onTaskEditCancel, hasMore, }: TaskListViewProps) { + const selectedIdSet = useMemo( + () => new Set(selectedTaskIds), + [selectedTaskIds], + ); + const hasMultiSelection = selectedTaskIds.length > 1; const organizeMode = useSidebarStore((state) => state.organizeMode); const sortMode = useSidebarStore((state) => state.sortMode); const collapsedSections = useSidebarStore((state) => state.collapsedSections); @@ -304,8 +317,10 @@ export function TaskListView({ key={task.id} task={task} isActive={activeTaskId === task.id} + isSelected={selectedIdSet.has(task.id)} + hideHoverActions={hasMultiSelection} isEditing={editingTaskId === task.id} - onClick={() => onTaskClick(task.id)} + onClick={(e) => onTaskClick(task.id, e)} onDoubleClick={() => onTaskDoubleClick(task.id)} onContextMenu={(e, isPinned) => onTaskContextMenu(task.id, e, isPinned) @@ -407,8 +422,10 @@ export function TaskListView({ key={task.id} task={task} isActive={activeTaskId === task.id} + isSelected={selectedIdSet.has(task.id)} + hideHoverActions={hasMultiSelection} isEditing={editingTaskId === task.id} - onClick={() => onTaskClick(task.id)} + onClick={(e) => onTaskClick(task.id, e)} onDoubleClick={() => onTaskDoubleClick(task.id)} onContextMenu={(e, isPinned) => onTaskContextMenu(task.id, e, isPinned) @@ -439,8 +456,10 @@ export function TaskListView({ key={task.id} task={task} isActive={activeTaskId === task.id} + isSelected={selectedIdSet.has(task.id)} + hideHoverActions={hasMultiSelection} isEditing={editingTaskId === task.id} - onClick={() => onTaskClick(task.id)} + onClick={(e) => onTaskClick(task.id, e)} onDoubleClick={() => onTaskDoubleClick(task.id)} onContextMenu={(e, isPinned) => onTaskContextMenu(task.id, e, isPinned) diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index eb604baeb..295a12505 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -24,6 +24,8 @@ interface TaskItemProps { taskId: string; label: string; isActive: boolean; + isSelected?: boolean; + hideHoverActions?: boolean; workspaceMode?: WorkspaceMode; worktreePath?: string; isGenerating?: boolean; @@ -36,7 +38,7 @@ interface TaskItemProps { hasDiff?: boolean; timestamp?: number; isEditing?: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onContextMenu: (e: React.MouseEvent) => void; onArchive?: () => void; @@ -228,6 +230,8 @@ export function TaskItem({ taskId, label, isActive, + isSelected = false, + hideHoverActions = false, workspaceMode, isSuspended = false, isGenerating, @@ -287,7 +291,7 @@ export function TaskItem({ ) : null; const toolbar = - onArchive || onTogglePin ? ( + !hideHoverActions && (onArchive || onTogglePin) ? ( { + beforeEach(() => { + useTaskSelectionStore.setState({ + selectedTaskIds: [], + lastClickedId: null, + }); + }); + + it("starts empty", () => { + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([]); + expect(useTaskSelectionStore.getState().lastClickedId).toBeNull(); + }); + + it("setSelectedTaskIds de-duplicates ids", () => { + useTaskSelectionStore + .getState() + .setSelectedTaskIds(["t1", "t2", "t1", "t3", "t2"]); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t1", + "t2", + "t3", + ]); + }); + + it("setSelectedTaskIds with a single id sets lastClickedId", () => { + useTaskSelectionStore.getState().setSelectedTaskIds(["t1"]); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("setSelectedTaskIds with multiple ids preserves existing lastClickedId", () => { + useTaskSelectionStore.setState({ lastClickedId: "t1" }); + useTaskSelectionStore.getState().setSelectedTaskIds(["t2", "t3"]); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("toggleTaskSelection adds an unselected task", () => { + useTaskSelectionStore.getState().toggleTaskSelection("t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t1"]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("toggleTaskSelection removes a selected task", () => { + useTaskSelectionStore.setState({ selectedTaskIds: ["t1", "t2"] }); + + useTaskSelectionStore.getState().toggleTaskSelection("t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t2"]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("isTaskSelected reflects selection state", () => { + useTaskSelectionStore.setState({ selectedTaskIds: ["t2"] }); + + expect(useTaskSelectionStore.getState().isTaskSelected("t1")).toBe(false); + expect(useTaskSelectionStore.getState().isTaskSelected("t2")).toBe(true); + }); + + it("clearSelection clears all selected tasks and lastClickedId", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1", "t2"], + lastClickedId: "t2", + }); + + useTaskSelectionStore.getState().clearSelection(); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([]); + expect(useTaskSelectionStore.getState().lastClickedId).toBeNull(); + }); + + it("pruneSelection keeps only visible task ids", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1", "t2", "t3"], + }); + + useTaskSelectionStore.getState().pruneSelection(["t2", "t4"]); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t2"]); + }); + + describe("selectRange", () => { + const orderedIds = ["t1", "t2", "t3", "t4", "t5"]; + + it("selects a forward range from anchor to target", () => { + useTaskSelectionStore.setState({ lastClickedId: "t2" }); + + useTaskSelectionStore.getState().selectRange("t4", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t2", + "t3", + "t4", + ]); + }); + + it("selects a backward range from anchor to target", () => { + useTaskSelectionStore.setState({ lastClickedId: "t4" }); + + useTaskSelectionStore.getState().selectRange("t2", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t2", + "t3", + "t4", + ]); + }); + + it("merges range with existing selection", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1"], + lastClickedId: "t3", + }); + + useTaskSelectionStore.getState().selectRange("t5", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t1", + "t3", + "t4", + "t5", + ]); + }); + + it("selects just the target when there is no anchor", () => { + useTaskSelectionStore.getState().selectRange("t3", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); + }); + + it("selects just the target when anchor is not in the ordered list", () => { + useTaskSelectionStore.setState({ lastClickedId: "t99" }); + + useTaskSelectionStore.getState().selectRange("t3", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); + }); + + it("uses fallbackAnchorId when there is no last-clicked anchor", () => { + useTaskSelectionStore.getState().selectRange("t4", orderedIds, "t2"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t2", + "t3", + "t4", + ]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t4"); + }); + + it("prefers lastClickedId over fallbackAnchorId when both are set", () => { + useTaskSelectionStore.setState({ lastClickedId: "t3" }); + + useTaskSelectionStore.getState().selectRange("t5", orderedIds, "t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t3", + "t4", + "t5", + ]); + }); + + it("updates lastClickedId to the target", () => { + useTaskSelectionStore.setState({ lastClickedId: "t1" }); + + useTaskSelectionStore.getState().selectRange("t3", orderedIds); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t3"); + }); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts b/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts new file mode 100644 index 000000000..eb2750fe7 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts @@ -0,0 +1,85 @@ +import { create } from "zustand"; + +interface TaskSelectionState { + selectedTaskIds: string[]; + /** The last task ID that was clicked — used as the anchor for shift-click range selection. */ + lastClickedId: string | null; +} + +interface TaskSelectionActions { + /** Replace the entire selection (plain click). */ + setSelectedTaskIds: (taskIds: string[]) => void; + /** Toggle a single task in/out of the selection (cmd-click). */ + toggleTaskSelection: (taskId: string) => void; + /** Select a contiguous range from the last-clicked task to `toId` within the given ordered list. + * Existing selection outside the range is preserved (shift-click behavior). + * If there is no last-clicked anchor (e.g. the user just navigated via a plain click), + * `fallbackAnchorId` is used — typically the currently active/routed task. */ + selectRange: ( + toId: string, + orderedIds: string[], + fallbackAnchorId?: string | null, + ) => void; + isTaskSelected: (taskId: string) => boolean; + clearSelection: () => void; + pruneSelection: (visibleTaskIds: string[]) => void; +} + +type TaskSelectionStore = TaskSelectionState & TaskSelectionActions; + +export const useTaskSelectionStore = create()( + (set, get) => ({ + selectedTaskIds: [], + lastClickedId: null, + + setSelectedTaskIds: (taskIds) => + set({ + selectedTaskIds: Array.from(new Set(taskIds)), + lastClickedId: taskIds.length === 1 ? taskIds[0] : get().lastClickedId, + }), + + toggleTaskSelection: (taskId) => + set((state) => { + const isRemoving = state.selectedTaskIds.includes(taskId); + return { + selectedTaskIds: isRemoving + ? state.selectedTaskIds.filter((id) => id !== taskId) + : [...state.selectedTaskIds, taskId], + lastClickedId: taskId, + }; + }), + + selectRange: (toId, orderedIds, fallbackAnchorId) => + set((state) => { + const anchorId = state.lastClickedId ?? fallbackAnchorId ?? null; + if (!anchorId) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const anchorIndex = orderedIds.indexOf(anchorId); + const toIndex = orderedIds.indexOf(toId); + if (anchorIndex === -1 || toIndex === -1) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const start = Math.min(anchorIndex, toIndex); + const end = Math.max(anchorIndex, toIndex); + const rangeIds = orderedIds.slice(start, end + 1); + const merged = Array.from( + new Set([...state.selectedTaskIds, ...rangeIds]), + ); + return { selectedTaskIds: merged, lastClickedId: toId }; + }), + + isTaskSelected: (taskId) => get().selectedTaskIds.includes(taskId), + + clearSelection: () => set({ selectedTaskIds: [], lastClickedId: null }), + + pruneSelection: (visibleTaskIds) => { + const visibleIds = new Set(visibleTaskIds); + set((state) => ({ + selectedTaskIds: state.selectedTaskIds.filter((id) => + visibleIds.has(id), + ), + })); + }, + }), +); diff --git a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts index 6552a87b2..084cb6518 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts @@ -92,6 +92,35 @@ export async function archiveTaskImperative( } } +export async function archiveTasksImperative( + taskIds: string[], + queryClient: QueryClient, +): Promise<{ archived: number; failed: number }> { + if (taskIds.length === 0) return { archived: 0, failed: 0 }; + + const nav = useNavigationStore.getState(); + const idSet = new Set(taskIds); + if ( + nav.view.type === "task-detail" && + nav.view.data && + idSet.has(nav.view.data.id) + ) { + nav.navigateToTaskInput(); + } + + let archived = 0; + let failed = 0; + for (const id of taskIds) { + try { + await archiveTaskImperative(id, queryClient, { skipNavigate: true }); + archived++; + } catch { + failed++; + } + } + return { archived, failed }; +} + export function useArchiveTask() { const queryClient = useQueryClient();