Skip to content

Commit ec0df1e

Browse files
committed
feat(sidebar): multi-select tasks with bulk archive
Cmd/Ctrl-click toggles a task in the selection; shift-click extends a range from the last clicked anchor. Right-clicking a task while 2+ are selected opens a bulk context menu offering "Archive N tasks" with a confirm dialog; right-clicking outside the selection clears it and falls through to the single-task menu. Escape (when no input is focused) clears the selection. The selection is held in a small Zustand store mirroring the inbox pattern, with a prune effect to drop ids that are no longer visible (e.g. after filters change). archiveTaskImperative gets a sibling archiveTasksImperative helper used by both the new bulk path and the existing "Archive prior tasks" action. Generated-By: PostHog Code Task-Id: a599e7e3-7c09-44dc-a847-551d283c9eb4
1 parent 7607b4c commit ec0df1e

11 files changed

Lines changed: 454 additions & 44 deletions

File tree

apps/code/src/main/services/context-menu/schemas.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const taskContextMenuInput = z.object({
1010
hasEmptyCommandCenterCell: z.boolean().optional(),
1111
});
1212

13+
export const bulkTaskContextMenuInput = z.object({
14+
taskCount: z.number().int().positive(),
15+
});
16+
1317
export const archivedTaskContextMenuInput = z.object({
1418
taskTitle: z.string(),
1519
});
@@ -45,6 +49,10 @@ const taskAction = z.discriminatedUnion("type", [
4549
z.object({ type: z.literal("external-app"), action: externalAppAction }),
4650
]);
4751

52+
const bulkTaskAction = z.discriminatedUnion("type", [
53+
z.object({ type: z.literal("archive") }),
54+
]);
55+
4856
const archivedTaskAction = z.discriminatedUnion("type", [
4957
z.object({ type: z.literal("restore") }),
5058
z.object({ type: z.literal("delete") }),
@@ -72,6 +80,9 @@ const splitDirection = z.enum(["left", "right", "up", "down"]);
7280
export const taskContextMenuOutput = z.object({
7381
action: taskAction.nullable(),
7482
});
83+
export const bulkTaskContextMenuOutput = z.object({
84+
action: bulkTaskAction.nullable(),
85+
});
7586
export const archivedTaskContextMenuOutput = z.object({
7687
action: archivedTaskAction.nullable(),
7788
});
@@ -87,6 +98,7 @@ export const splitContextMenuOutput = z.object({
8798
});
8899

89100
export type TaskContextMenuInput = z.infer<typeof taskContextMenuInput>;
101+
export type BulkTaskContextMenuInput = z.infer<typeof bulkTaskContextMenuInput>;
90102
export type ArchivedTaskContextMenuInput = z.infer<
91103
typeof archivedTaskContextMenuInput
92104
>;
@@ -96,6 +108,7 @@ export type FileContextMenuInput = z.infer<typeof fileContextMenuInput>;
96108

97109
export type ExternalAppAction = z.infer<typeof externalAppAction>;
98110
export type TaskAction = z.infer<typeof taskAction>;
111+
export type BulkTaskAction = z.infer<typeof bulkTaskAction>;
99112
export type ArchivedTaskAction = z.infer<typeof archivedTaskAction>;
100113
export type FolderAction = z.infer<typeof folderAction>;
101114
export type TabAction = z.infer<typeof tabAction>;

apps/code/src/main/services/context-menu/service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
ArchivedTaskAction,
1212
ArchivedTaskContextMenuInput,
1313
ArchivedTaskContextMenuResult,
14+
BulkTaskAction,
15+
BulkTaskContextMenuInput,
1416
ConfirmDeleteArchivedTaskInput,
1517
ConfirmDeleteArchivedTaskResult,
1618
ConfirmDeleteTaskInput,
@@ -160,6 +162,27 @@ export class ContextMenuService {
160162
]);
161163
}
162164

165+
async showBulkTaskContextMenu(
166+
input: BulkTaskContextMenuInput,
167+
): Promise<{ action: BulkTaskAction | null }> {
168+
const { taskCount } = input;
169+
const label = `Archive ${taskCount} tasks`;
170+
return this.showMenu<BulkTaskAction>([
171+
this.item(
172+
label,
173+
{ type: "archive" },
174+
{
175+
confirm: {
176+
title: "Archive Tasks",
177+
message: `Archive ${taskCount} tasks?`,
178+
detail: "You can unarchive them later.",
179+
confirmLabel: "Archive",
180+
},
181+
},
182+
),
183+
]);
184+
}
185+
163186
async showArchivedTaskContextMenu(
164187
input: ArchivedTaskContextMenuInput,
165188
): Promise<ArchivedTaskContextMenuResult> {

apps/code/src/main/trpc/routers/context-menu.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { MAIN_TOKENS } from "../../di/tokens";
33
import {
44
archivedTaskContextMenuInput,
55
archivedTaskContextMenuOutput,
6+
bulkTaskContextMenuInput,
7+
bulkTaskContextMenuOutput,
68
confirmDeleteArchivedTaskInput,
79
confirmDeleteArchivedTaskOutput,
810
confirmDeleteTaskInput,
@@ -46,6 +48,11 @@ export const contextMenuRouter = router({
4648
.output(taskContextMenuOutput)
4749
.mutation(({ input }) => getService().showTaskContextMenu(input)),
4850

51+
showBulkTaskContextMenu: publicProcedure
52+
.input(bulkTaskContextMenuInput)
53+
.output(bulkTaskContextMenuOutput)
54+
.mutation(({ input }) => getService().showBulkTaskContextMenu(input)),
55+
4956
showArchivedTaskContextMenu: publicProcedure
5057
.input(archivedTaskContextMenuInput)
5158
.output(archivedTaskContextMenuOutput)

apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { useWorkspaces } from "@features/workspace/hooks/useWorkspace";
33
import { Box } from "@radix-ui/themes";
44
import { useEffect } from "react";
55
import { useSidebarStore } from "../stores/sidebarStore";
6+
import { useTaskSelectionStore } from "../stores/taskSelectionStore";
67
import { Sidebar, SidebarContent } from "./index";
78

9+
function isEditableTarget(target: EventTarget | null): boolean {
10+
if (!(target instanceof HTMLElement)) return false;
11+
if (target.isContentEditable) return true;
12+
const tag = target.tagName;
13+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
14+
}
15+
816
export function MainSidebar() {
917
const { data: workspaces = {}, isFetched } = useWorkspaces();
1018
const hasCompletedOnboarding = useOnboardingStore(
@@ -19,6 +27,19 @@ export function MainSidebar() {
1927
}
2028
}, [isFetched, workspaces, hasCompletedOnboarding, setOpenAuto]);
2129

30+
useEffect(() => {
31+
const handler = (e: KeyboardEvent) => {
32+
if (e.key !== "Escape") return;
33+
if (isEditableTarget(e.target)) return;
34+
const { selectedTaskIds, clearSelection } =
35+
useTaskSelectionStore.getState();
36+
if (selectedTaskIds.length === 0) return;
37+
clearSelection();
38+
};
39+
window.addEventListener("keydown", handler);
40+
return () => window.removeEventListener("keydown", handler);
41+
}, []);
42+
2243
return (
2344
<Box flexShrink="0" className="shrink-0">
2445
<Sidebar>

apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ interface SidebarItemProps {
1111
label: React.ReactNode;
1212
subtitle?: React.ReactNode;
1313
isActive?: boolean;
14+
isSelected?: boolean;
1415
isDimmed?: boolean;
1516
draggable?: boolean;
1617
onDragStart?: (e: React.DragEvent) => void;
17-
onClick?: () => void;
18+
onClick?: (e: React.MouseEvent) => void;
1819
onDoubleClick?: () => void;
1920
onContextMenu?: (e: React.MouseEvent) => void;
2021
action?: SidebarItemAction;
@@ -28,6 +29,7 @@ export function SidebarItem({
2829
label,
2930
subtitle,
3031
isActive,
32+
isSelected,
3133
draggable,
3234
onDragStart,
3335
onClick,
@@ -69,9 +71,10 @@ export function SidebarItem({
6971
type="button"
7072
className={cn(
7173
"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",
72-
"cursor-default disabled:opacity-100 data-active:bg-fill-selected",
74+
"cursor-default disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)",
7375
)}
7476
data-active={isActive || undefined}
77+
data-selected={(isSelected && !isActive) || undefined}
7578
draggable={draggable}
7679
onDragStart={onDragStart}
7780
style={{

apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"
1010
import { getSessionService } from "@features/sessions/service/service";
1111
import { useSetupStore } from "@features/setup/stores/setupStore";
1212
import {
13-
archiveTaskImperative,
13+
archiveTasksImperative,
1414
useArchiveTask,
1515
} from "@features/tasks/hooks/useArchiveTask";
1616
import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks";
@@ -19,17 +19,19 @@ import { useTaskContextMenu } from "@hooks/useTaskContextMenu";
1919
import { ScrollArea, Separator } from "@posthog/quill";
2020
import { Box, Flex } from "@radix-ui/themes";
2121
import type { Schemas } from "@renderer/api/generated";
22+
import { trpcClient } from "@renderer/trpc/client";
2223
import type { Task } from "@shared/types";
2324
import { useNavigationStore } from "@stores/navigationStore";
2425
import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
2526
import { useQueryClient } from "@tanstack/react-query";
2627
import { logger } from "@utils/logger";
2728
import { toast } from "@utils/toast";
28-
import { memo, useCallback, useEffect, useRef } from "react";
29+
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
2930
import { usePinnedTasks } from "../hooks/usePinnedTasks";
3031
import { useSidebarData } from "../hooks/useSidebarData";
3132
import { useTaskViewed } from "../hooks/useTaskViewed";
3233
import { useSidebarStore } from "../stores/sidebarStore";
34+
import { useTaskSelectionStore } from "../stores/taskSelectionStore";
3335
import { CommandCenterItem } from "./items/CommandCenterItem";
3436
import { InboxItem, NewTaskItem } from "./items/HomeItem";
3537
import { McpServersItem } from "./items/McpServersItem";
@@ -145,23 +147,97 @@ function SidebarMenuComponent() {
145147
navigateToSetup();
146148
};
147149

148-
const handleTaskClick = (taskId: string) => {
150+
const queryClient = useQueryClient();
151+
152+
const selectedTaskIds = useTaskSelectionStore((s) => s.selectedTaskIds);
153+
const toggleTaskSelection = useTaskSelectionStore(
154+
(s) => s.toggleTaskSelection,
155+
);
156+
const selectRange = useTaskSelectionStore((s) => s.selectRange);
157+
const clearSelection = useTaskSelectionStore((s) => s.clearSelection);
158+
const pruneSelection = useTaskSelectionStore((s) => s.pruneSelection);
159+
160+
const allSidebarTasks = useMemo(
161+
() => [...sidebarData.pinnedTasks, ...sidebarData.flatTasks],
162+
[sidebarData.pinnedTasks, sidebarData.flatTasks],
163+
);
164+
165+
const allSidebarTaskIds = useMemo(
166+
() => allSidebarTasks.map((t) => t.id),
167+
[allSidebarTasks],
168+
);
169+
170+
useEffect(() => {
171+
pruneSelection(allSidebarTaskIds);
172+
}, [allSidebarTaskIds, pruneSelection]);
173+
174+
const handleTaskClick = (taskId: string, e: React.MouseEvent) => {
175+
if (e.shiftKey) {
176+
e.preventDefault();
177+
selectRange(taskId, allSidebarTaskIds);
178+
return;
179+
}
180+
if (e.metaKey || e.ctrlKey) {
181+
e.preventDefault();
182+
toggleTaskSelection(taskId);
183+
return;
184+
}
185+
186+
clearSelection();
149187
const task = taskMap.get(taskId);
150188
if (task) {
151189
navigateToTask(task);
152190
}
153191
};
154192

155-
const allSidebarTasks = [
156-
...sidebarData.pinnedTasks,
157-
...sidebarData.flatTasks,
158-
];
193+
const handleBulkContextMenu = useCallback(
194+
async (e: React.MouseEvent, taskIds: string[]) => {
195+
e.preventDefault();
196+
e.stopPropagation();
197+
try {
198+
const result =
199+
await trpcClient.contextMenu.showBulkTaskContextMenu.mutate({
200+
taskCount: taskIds.length,
201+
});
202+
if (!result.action) return;
203+
if (result.action.type === "archive") {
204+
const { archived, failed } = await archiveTasksImperative(
205+
taskIds,
206+
queryClient,
207+
);
208+
clearSelection();
209+
if (failed === 0) {
210+
toast.success(
211+
`${archived} ${archived === 1 ? "task" : "tasks"} archived`,
212+
);
213+
} else {
214+
toast.error(`${archived} archived, ${failed} failed`);
215+
}
216+
}
217+
} catch (error) {
218+
logger
219+
.scope("sidebar-menu")
220+
.error("Failed to show bulk context menu", error);
221+
}
222+
},
223+
[queryClient, clearSelection],
224+
);
159225

160226
const handleTaskContextMenu = (
161227
taskId: string,
162228
e: React.MouseEvent,
163229
isPinned: boolean,
164230
) => {
231+
// Bulk menu when 2+ tasks are selected and the right-clicked task is in the selection.
232+
// Otherwise clear the selection (right-click outside) and fall through to the single menu.
233+
if (selectedTaskIds.length > 1) {
234+
if (selectedTaskIds.includes(taskId)) {
235+
handleBulkContextMenu(e, selectedTaskIds);
236+
return;
237+
}
238+
clearSelection();
239+
}
240+
165241
const task = taskMap.get(taskId);
166242
if (task) {
167243
const workspace = workspaces[taskId];
@@ -201,52 +277,34 @@ function SidebarMenuComponent() {
201277
};
202278

203279
const updateTask = useUpdateTask();
204-
const queryClient = useQueryClient();
205280

206281
const handleArchivePrior = useCallback(
207282
async (taskId: string) => {
208283
const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks];
209284
const clickedTask = allVisible.find((t) => t.id === taskId);
210285
if (!clickedTask) return;
211286

212-
const sortKey = "createdAt" as const;
213-
const threshold = clickedTask[sortKey];
287+
const threshold = clickedTask.createdAt;
214288
const priorTaskIds = allVisible
215-
.filter((t) => t.id !== taskId && t[sortKey] < threshold)
289+
.filter((t) => t.id !== taskId && t.createdAt < threshold)
216290
.map((t) => t.id);
217291

218292
if (priorTaskIds.length === 0) {
219293
toast.info("No older tasks to archive");
220294
return;
221295
}
222296

223-
const nav = useNavigationStore.getState();
224-
const priorSet = new Set(priorTaskIds);
225-
if (
226-
nav.view.type === "task-detail" &&
227-
nav.view.data &&
228-
priorSet.has(nav.view.data.id)
229-
) {
230-
nav.navigateToTaskInput();
231-
}
232-
233-
let done = 0;
234-
let failed = 0;
235-
for (const id of priorTaskIds) {
236-
try {
237-
await archiveTaskImperative(id, queryClient, {
238-
skipNavigate: true,
239-
});
240-
done++;
241-
} catch {
242-
failed++;
243-
}
244-
}
297+
const { archived, failed } = await archiveTasksImperative(
298+
priorTaskIds,
299+
queryClient,
300+
);
245301

246302
if (failed === 0) {
247-
toast.success(`${done} ${done === 1 ? "task" : "tasks"} archived`);
303+
toast.success(
304+
`${archived} ${archived === 1 ? "task" : "tasks"} archived`,
305+
);
248306
} else {
249-
toast.error(`${done} archived, ${failed} failed`);
307+
toast.error(`${archived} archived, ${failed} failed`);
250308
}
251309
},
252310
[sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient],
@@ -371,6 +429,7 @@ function SidebarMenuComponent() {
371429
groupedTasks={sidebarData.groupedTasks}
372430
activeTaskId={sidebarData.activeTaskId}
373431
editingTaskId={editingTaskId}
432+
selectedTaskIds={selectedTaskIds}
374433
onTaskClick={handleTaskClick}
375434
onTaskDoubleClick={handleTaskDoubleClick}
376435
onTaskContextMenu={handleTaskContextMenu}

0 commit comments

Comments
 (0)