Skip to content

Commit 4cf2dca

Browse files
authored
feat: Add bulk archive and clean up task context menu (#1622)
## Problem Archiving old tasks one at a time is tedious and the context menu had disorganized grouping with incorrect suspend visibility. <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## ![CleanShot 2026-04-13 at 11.00.59@2x.png](https://app.graphite.com/user-attachments/assets/dee250ea-6bf4-40e2-820a-227f2f744aff.png) ## Changes 1. Add "Archive prior tasks" context menu action that archives all tasks older than the clicked one 2. Extract archiveTaskImperative for reuse outside React hooks 3. Reorganize context menu: quick actions on top, workspace actions in middle, archive at bottom 4. Fix Suspend showing for non-worktree tasks (was incorrectly using folderPath fallback) 5. Toggle Suspend/Unsuspend label based on task state 6. Remove Delete from context menu in favor of archiving <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? Manually <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent eaa2adb commit 4cf2dca

File tree

5 files changed

+192
-80
lines changed

5 files changed

+192
-80
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { z } from "zod";
33
export const taskContextMenuInput = z.object({
44
taskTitle: z.string(),
55
worktreePath: z.string().optional(),
6+
folderPath: z.string().optional(),
67
isPinned: z.boolean().optional(),
8+
isSuspended: z.boolean().optional(),
79
});
810

911
export const archivedTaskContextMenuInput = z.object({
@@ -36,6 +38,7 @@ const taskAction = z.discriminatedUnion("type", [
3638
z.object({ type: z.literal("copy-task-id") }),
3739
z.object({ type: z.literal("suspend") }),
3840
z.object({ type: z.literal("archive") }),
41+
z.object({ type: z.literal("archive-prior") }),
3942
z.object({ type: z.literal("delete") }),
4043
z.object({ type: z.literal("external-app"), action: externalAppAction }),
4144
]);

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,25 +104,43 @@ export class ContextMenuService {
104104
async showTaskContextMenu(
105105
input: TaskContextMenuInput,
106106
): Promise<TaskContextMenuResult> {
107-
const { worktreePath, isPinned } = input;
107+
const { worktreePath, folderPath, isPinned, isSuspended } = input;
108108
const { apps, lastUsedAppId } = await this.getExternalAppsData();
109+
const hasPath = worktreePath || folderPath;
109110

110111
return this.showMenu<TaskAction>([
111-
this.item("Rename", { type: "rename" }),
112112
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
113+
this.item("Rename", { type: "rename" }),
113114
this.item("Copy Task ID", { type: "copy-task-id" }),
114-
this.separator(),
115-
...(worktreePath
116-
? [this.item("Suspend", { type: "suspend" as const })]
117-
: []),
118-
this.item("Archive", { type: "archive" }),
119-
this.item("Delete", { type: "delete" }),
120115
...(worktreePath
121116
? [
122117
this.separator(),
118+
this.item(isSuspended ? "Unsuspend" : "Suspend", {
119+
type: "suspend" as const,
120+
}),
121+
]
122+
: []),
123+
...(hasPath
124+
? [
125+
...(worktreePath ? [] : [this.separator()]),
123126
...this.externalAppItems<TaskAction>(apps, lastUsedAppId),
124127
]
125128
: []),
129+
this.separator(),
130+
this.item("Archive", { type: "archive" }),
131+
this.item(
132+
"Archive prior tasks",
133+
{ type: "archive-prior" },
134+
{
135+
confirm: {
136+
title: "Archive Prior Tasks",
137+
message: "Archive all tasks older than this one?",
138+
detail:
139+
"This will archive every task created before this one. You can unarchive them later.",
140+
confirmLabel: "Archive",
141+
},
142+
},
143+
),
126144
]);
127145
}
128146

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

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
INBOX_REFETCH_INTERVAL_MS,
77
} from "@features/inbox/utils/inboxConstants";
88
import { getSessionService } from "@features/sessions/service/service";
9-
import { useArchiveTask } from "@features/tasks/hooks/useArchiveTask";
9+
import {
10+
archiveTaskImperative,
11+
useArchiveTask,
12+
} from "@features/tasks/hooks/useArchiveTask";
1013
import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks";
1114
import { useWorkspaces } from "@features/workspace/hooks/useWorkspace";
1215
import { useTaskContextMenu } from "@hooks/useTaskContextMenu";
@@ -16,6 +19,7 @@ import { useNavigationStore } from "@stores/navigationStore";
1619
import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
1720
import { useQueryClient } from "@tanstack/react-query";
1821
import { logger } from "@utils/logger";
22+
import { toast } from "@utils/toast";
1923
import { memo, useCallback, useEffect, useRef } from "react";
2024
import { usePinnedTasks } from "../hooks/usePinnedTasks";
2125
import { useSidebarData } from "../hooks/useSidebarData";
@@ -116,6 +120,11 @@ function SidebarMenuComponent() {
116120
}
117121
};
118122

123+
const allSidebarTasks = [
124+
...sidebarData.pinnedTasks,
125+
...sidebarData.flatTasks,
126+
];
127+
119128
const handleTaskContextMenu = (
120129
taskId: string,
121130
e: React.MouseEvent,
@@ -124,11 +133,14 @@ function SidebarMenuComponent() {
124133
const task = taskMap.get(taskId);
125134
if (task) {
126135
const workspace = workspaces[taskId];
127-
const effectivePath = workspace?.worktreePath ?? workspace?.folderPath;
136+
const taskData = allSidebarTasks.find((t) => t.id === taskId);
128137
showContextMenu(task, e, {
129-
worktreePath: effectivePath ?? undefined,
138+
worktreePath: workspace?.worktreePath ?? undefined,
139+
folderPath: workspace?.folderPath ?? undefined,
130140
isPinned,
141+
isSuspended: taskData?.isSuspended,
131142
onTogglePin: () => togglePin(taskId),
143+
onArchivePrior: handleArchivePrior,
132144
});
133145
}
134146
};
@@ -139,6 +151,55 @@ function SidebarMenuComponent() {
139151

140152
const updateTask = useUpdateTask();
141153
const queryClient = useQueryClient();
154+
155+
const handleArchivePrior = useCallback(
156+
async (taskId: string) => {
157+
const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks];
158+
const clickedTask = allVisible.find((t) => t.id === taskId);
159+
if (!clickedTask) return;
160+
161+
const sortKey = "createdAt" as const;
162+
const threshold = clickedTask[sortKey];
163+
const priorTaskIds = allVisible
164+
.filter((t) => t.id !== taskId && t[sortKey] < threshold)
165+
.map((t) => t.id);
166+
167+
if (priorTaskIds.length === 0) {
168+
toast.info("No older tasks to archive");
169+
return;
170+
}
171+
172+
const nav = useNavigationStore.getState();
173+
const priorSet = new Set(priorTaskIds);
174+
if (
175+
nav.view.type === "task-detail" &&
176+
nav.view.data &&
177+
priorSet.has(nav.view.data.id)
178+
) {
179+
nav.navigateToTaskInput();
180+
}
181+
182+
let done = 0;
183+
let failed = 0;
184+
for (const id of priorTaskIds) {
185+
try {
186+
await archiveTaskImperative(id, queryClient, {
187+
skipNavigate: true,
188+
});
189+
done++;
190+
} catch {
191+
failed++;
192+
}
193+
}
194+
195+
if (failed === 0) {
196+
toast.success(`${done} ${done === 1 ? "task" : "tasks"} archived`);
197+
} else {
198+
toast.error(`${done} archived, ${failed} failed`);
199+
}
200+
},
201+
[sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient],
202+
);
142203
const log = logger.scope("sidebar-menu");
143204

144205
const handleTaskDoubleClick = useCallback(

apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts

Lines changed: 70 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,90 +7,97 @@ import { trpc, trpcClient } from "@renderer/trpc";
77
import type { ArchivedTask } from "@shared/types/archive";
88
import { useFocusStore } from "@stores/focusStore";
99
import { useNavigationStore } from "@stores/navigationStore";
10-
import { useQueryClient } from "@tanstack/react-query";
10+
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
1111
import { logger } from "@utils/logger";
1212
import { toast } from "@utils/toast";
1313

1414
const log = logger.scope("archive-task");
1515

16-
interface ArchiveTaskInput {
17-
taskId: string;
16+
interface ArchiveTaskOptions {
17+
skipNavigate?: boolean;
1818
}
1919

20-
export function useArchiveTask() {
21-
const queryClient = useQueryClient();
22-
23-
const archiveTask = async (input: ArchiveTaskInput) => {
24-
const { taskId } = input;
25-
const focusStore = useFocusStore.getState();
26-
const workspace = await workspaceApi.get(taskId);
27-
const pinnedTaskIds = await pinnedTasksApi.getPinnedTaskIds();
28-
const wasPinned = pinnedTaskIds.includes(taskId);
29-
20+
export async function archiveTaskImperative(
21+
taskId: string,
22+
queryClient: QueryClient,
23+
options?: ArchiveTaskOptions,
24+
): Promise<void> {
25+
const focusStore = useFocusStore.getState();
26+
const workspace = await workspaceApi.get(taskId);
27+
const pinnedTaskIds = await pinnedTasksApi.getPinnedTaskIds();
28+
const wasPinned = pinnedTaskIds.includes(taskId);
29+
30+
if (!options?.skipNavigate) {
3031
const nav = useNavigationStore.getState();
3132
if (nav.view.type === "task-detail" && nav.view.data?.id === taskId) {
3233
nav.navigateToTaskInput();
3334
}
35+
}
36+
37+
pinnedTasksApi.unpin(taskId);
38+
useTerminalStore.getState().clearTerminalStatesForTask(taskId);
39+
useCommandCenterStore.getState().removeTaskById(taskId);
40+
41+
queryClient.setQueryData<string[]>(
42+
trpc.archive.archivedTaskIds.queryKey(),
43+
(old) => (old ? [...old, taskId] : [taskId]),
44+
);
45+
46+
const optimisticArchived: ArchivedTask = {
47+
taskId,
48+
archivedAt: new Date().toISOString(),
49+
folderId: workspace?.folderId ?? "",
50+
mode: workspace?.mode ?? "worktree",
51+
worktreeName: workspace?.worktreeName ?? null,
52+
branchName: workspace?.branchName ?? null,
53+
checkpointId: null,
54+
};
55+
queryClient.setQueryData<ArchivedTask[]>(
56+
trpc.archive.list.queryKey(),
57+
(old) => (old ? [...old, optimisticArchived] : [optimisticArchived]),
58+
);
59+
60+
if (
61+
workspace?.worktreePath &&
62+
focusStore.session?.worktreePath === workspace.worktreePath
63+
) {
64+
log.info("Unfocusing workspace before archiving");
65+
await focusStore.disableFocus();
66+
}
67+
68+
try {
69+
await getSessionService().disconnectFromTask(taskId);
70+
71+
await trpcClient.archive.archive.mutate({
72+
taskId,
73+
});
3474

35-
pinnedTasksApi.unpin(taskId);
36-
useTerminalStore.getState().clearTerminalStatesForTask(taskId);
37-
useCommandCenterStore.getState().removeTaskById(taskId);
75+
queryClient.invalidateQueries(trpc.archive.pathFilter());
76+
} catch (error) {
77+
log.error("Failed to archive task", error);
3878

3979
queryClient.setQueryData<string[]>(
4080
trpc.archive.archivedTaskIds.queryKey(),
41-
(old) => (old ? [...old, taskId] : [taskId]),
81+
(old) => (old ? old.filter((id) => id !== taskId) : []),
4282
);
43-
44-
const optimisticArchived: ArchivedTask = {
45-
taskId,
46-
archivedAt: new Date().toISOString(),
47-
folderId: workspace?.folderId ?? "",
48-
mode: workspace?.mode ?? "worktree",
49-
worktreeName: workspace?.worktreeName ?? null,
50-
branchName: workspace?.branchName ?? null,
51-
checkpointId: null,
52-
};
5383
queryClient.setQueryData<ArchivedTask[]>(
5484
trpc.archive.list.queryKey(),
55-
(old) => (old ? [...old, optimisticArchived] : [optimisticArchived]),
85+
(old) => (old ? old.filter((a) => a.taskId !== taskId) : []),
5686
);
57-
58-
if (
59-
workspace?.worktreePath &&
60-
focusStore.session?.worktreePath === workspace.worktreePath
61-
) {
62-
log.info("Unfocusing workspace before archiving");
63-
await focusStore.disableFocus();
87+
if (wasPinned) {
88+
pinnedTasksApi.togglePin(taskId);
6489
}
6590

66-
try {
67-
await getSessionService().disconnectFromTask(taskId);
68-
69-
await trpcClient.archive.archive.mutate({
70-
taskId,
71-
});
72-
73-
queryClient.invalidateQueries(trpc.archive.pathFilter());
74-
75-
toast.success("Task archived");
76-
} catch (error) {
77-
log.error("Failed to archive task", error);
78-
toast.error("Failed to archive task");
79-
80-
queryClient.setQueryData<string[]>(
81-
trpc.archive.archivedTaskIds.queryKey(),
82-
(old) => (old ? old.filter((id) => id !== taskId) : []),
83-
);
84-
queryClient.setQueryData<ArchivedTask[]>(
85-
trpc.archive.list.queryKey(),
86-
(old) => (old ? old.filter((a) => a.taskId !== taskId) : []),
87-
);
88-
if (wasPinned) {
89-
pinnedTasksApi.togglePin(taskId);
90-
}
91-
92-
throw error;
93-
}
91+
throw error;
92+
}
93+
}
94+
95+
export function useArchiveTask() {
96+
const queryClient = useQueryClient();
97+
98+
const archiveTask = async ({ taskId }: { taskId: string }) => {
99+
await archiveTaskImperative(taskId, queryClient);
100+
toast.success("Task archived");
94101
};
95102

96103
return { archiveTask };

0 commit comments

Comments
 (0)