diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 62238fa06..d11d80eb4 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -3,7 +3,9 @@ import { z } from "zod"; export const taskContextMenuInput = z.object({ taskTitle: z.string(), worktreePath: z.string().optional(), + folderPath: z.string().optional(), isPinned: z.boolean().optional(), + isSuspended: z.boolean().optional(), }); export const archivedTaskContextMenuInput = z.object({ @@ -36,6 +38,7 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("copy-task-id") }), z.object({ type: z.literal("suspend") }), z.object({ type: z.literal("archive") }), + z.object({ type: z.literal("archive-prior") }), z.object({ type: z.literal("delete") }), z.object({ type: z.literal("external-app"), action: externalAppAction }), ]); diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index d30e81022..adb49f1da 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -104,25 +104,43 @@ export class ContextMenuService { async showTaskContextMenu( input: TaskContextMenuInput, ): Promise { - const { worktreePath, isPinned } = input; + const { worktreePath, folderPath, isPinned, isSuspended } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); + const hasPath = worktreePath || folderPath; return this.showMenu([ - this.item("Rename", { type: "rename" }), this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), + this.item("Rename", { type: "rename" }), this.item("Copy Task ID", { type: "copy-task-id" }), - this.separator(), - ...(worktreePath - ? [this.item("Suspend", { type: "suspend" as const })] - : []), - this.item("Archive", { type: "archive" }), - this.item("Delete", { type: "delete" }), ...(worktreePath ? [ this.separator(), + this.item(isSuspended ? "Unsuspend" : "Suspend", { + type: "suspend" as const, + }), + ] + : []), + ...(hasPath + ? [ + ...(worktreePath ? [] : [this.separator()]), ...this.externalAppItems(apps, lastUsedAppId), ] : []), + this.separator(), + this.item("Archive", { type: "archive" }), + this.item( + "Archive prior tasks", + { type: "archive-prior" }, + { + confirm: { + title: "Archive Prior Tasks", + message: "Archive all tasks older than this one?", + detail: + "This will archive every task created before this one. You can unarchive them later.", + confirmLabel: "Archive", + }, + }, + ), ]); } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index b6214297d..9e0eb6fb4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,7 +6,10 @@ import { INBOX_REFETCH_INTERVAL_MS, } from "@features/inbox/utils/inboxConstants"; import { getSessionService } from "@features/sessions/service/service"; -import { useArchiveTask } from "@features/tasks/hooks/useArchiveTask"; +import { + archiveTaskImperative, + useArchiveTask, +} from "@features/tasks/hooks/useArchiveTask"; import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; @@ -16,6 +19,7 @@ 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 { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; @@ -116,6 +120,11 @@ function SidebarMenuComponent() { } }; + const allSidebarTasks = [ + ...sidebarData.pinnedTasks, + ...sidebarData.flatTasks, + ]; + const handleTaskContextMenu = ( taskId: string, e: React.MouseEvent, @@ -124,11 +133,14 @@ function SidebarMenuComponent() { const task = taskMap.get(taskId); if (task) { const workspace = workspaces[taskId]; - const effectivePath = workspace?.worktreePath ?? workspace?.folderPath; + const taskData = allSidebarTasks.find((t) => t.id === taskId); showContextMenu(task, e, { - worktreePath: effectivePath ?? undefined, + worktreePath: workspace?.worktreePath ?? undefined, + folderPath: workspace?.folderPath ?? undefined, isPinned, + isSuspended: taskData?.isSuspended, onTogglePin: () => togglePin(taskId), + onArchivePrior: handleArchivePrior, }); } }; @@ -139,6 +151,55 @@ function SidebarMenuComponent() { const updateTask = useUpdateTask(); const queryClient = useQueryClient(); + + const handleArchivePrior = useCallback( + async (taskId: string) => { + const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks]; + const clickedTask = allVisible.find((t) => t.id === taskId); + if (!clickedTask) return; + + const sortKey = "createdAt" as const; + const threshold = clickedTask[sortKey]; + const priorTaskIds = allVisible + .filter((t) => t.id !== taskId && t[sortKey] < threshold) + .map((t) => t.id); + + if (priorTaskIds.length === 0) { + toast.info("No older tasks to archive"); + 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++; + } + } + + if (failed === 0) { + toast.success(`${done} ${done === 1 ? "task" : "tasks"} archived`); + } else { + toast.error(`${done} archived, ${failed} failed`); + } + }, + [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], + ); const log = logger.scope("sidebar-menu"); const handleTaskDoubleClick = useCallback( diff --git a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts index 6f4d8b6b3..6552a87b2 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts @@ -7,90 +7,97 @@ import { trpc, trpcClient } from "@renderer/trpc"; import type { ArchivedTask } from "@shared/types/archive"; import { useFocusStore } from "@stores/focusStore"; import { useNavigationStore } from "@stores/navigationStore"; -import { useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; const log = logger.scope("archive-task"); -interface ArchiveTaskInput { - taskId: string; +interface ArchiveTaskOptions { + skipNavigate?: boolean; } -export function useArchiveTask() { - const queryClient = useQueryClient(); - - const archiveTask = async (input: ArchiveTaskInput) => { - const { taskId } = input; - const focusStore = useFocusStore.getState(); - const workspace = await workspaceApi.get(taskId); - const pinnedTaskIds = await pinnedTasksApi.getPinnedTaskIds(); - const wasPinned = pinnedTaskIds.includes(taskId); - +export async function archiveTaskImperative( + taskId: string, + queryClient: QueryClient, + options?: ArchiveTaskOptions, +): Promise { + const focusStore = useFocusStore.getState(); + const workspace = await workspaceApi.get(taskId); + const pinnedTaskIds = await pinnedTasksApi.getPinnedTaskIds(); + const wasPinned = pinnedTaskIds.includes(taskId); + + if (!options?.skipNavigate) { const nav = useNavigationStore.getState(); if (nav.view.type === "task-detail" && nav.view.data?.id === taskId) { nav.navigateToTaskInput(); } + } + + pinnedTasksApi.unpin(taskId); + useTerminalStore.getState().clearTerminalStatesForTask(taskId); + useCommandCenterStore.getState().removeTaskById(taskId); + + queryClient.setQueryData( + trpc.archive.archivedTaskIds.queryKey(), + (old) => (old ? [...old, taskId] : [taskId]), + ); + + const optimisticArchived: ArchivedTask = { + taskId, + archivedAt: new Date().toISOString(), + folderId: workspace?.folderId ?? "", + mode: workspace?.mode ?? "worktree", + worktreeName: workspace?.worktreeName ?? null, + branchName: workspace?.branchName ?? null, + checkpointId: null, + }; + queryClient.setQueryData( + trpc.archive.list.queryKey(), + (old) => (old ? [...old, optimisticArchived] : [optimisticArchived]), + ); + + if ( + workspace?.worktreePath && + focusStore.session?.worktreePath === workspace.worktreePath + ) { + log.info("Unfocusing workspace before archiving"); + await focusStore.disableFocus(); + } + + try { + await getSessionService().disconnectFromTask(taskId); + + await trpcClient.archive.archive.mutate({ + taskId, + }); - pinnedTasksApi.unpin(taskId); - useTerminalStore.getState().clearTerminalStatesForTask(taskId); - useCommandCenterStore.getState().removeTaskById(taskId); + queryClient.invalidateQueries(trpc.archive.pathFilter()); + } catch (error) { + log.error("Failed to archive task", error); queryClient.setQueryData( trpc.archive.archivedTaskIds.queryKey(), - (old) => (old ? [...old, taskId] : [taskId]), + (old) => (old ? old.filter((id) => id !== taskId) : []), ); - - const optimisticArchived: ArchivedTask = { - taskId, - archivedAt: new Date().toISOString(), - folderId: workspace?.folderId ?? "", - mode: workspace?.mode ?? "worktree", - worktreeName: workspace?.worktreeName ?? null, - branchName: workspace?.branchName ?? null, - checkpointId: null, - }; queryClient.setQueryData( trpc.archive.list.queryKey(), - (old) => (old ? [...old, optimisticArchived] : [optimisticArchived]), + (old) => (old ? old.filter((a) => a.taskId !== taskId) : []), ); - - if ( - workspace?.worktreePath && - focusStore.session?.worktreePath === workspace.worktreePath - ) { - log.info("Unfocusing workspace before archiving"); - await focusStore.disableFocus(); + if (wasPinned) { + pinnedTasksApi.togglePin(taskId); } - try { - await getSessionService().disconnectFromTask(taskId); - - await trpcClient.archive.archive.mutate({ - taskId, - }); - - queryClient.invalidateQueries(trpc.archive.pathFilter()); - - toast.success("Task archived"); - } catch (error) { - log.error("Failed to archive task", error); - toast.error("Failed to archive task"); - - queryClient.setQueryData( - trpc.archive.archivedTaskIds.queryKey(), - (old) => (old ? old.filter((id) => id !== taskId) : []), - ); - queryClient.setQueryData( - trpc.archive.list.queryKey(), - (old) => (old ? old.filter((a) => a.taskId !== taskId) : []), - ); - if (wasPinned) { - pinnedTasksApi.togglePin(taskId); - } - - throw error; - } + throw error; + } +} + +export function useArchiveTask() { + const queryClient = useQueryClient(); + + const archiveTask = async ({ taskId }: { taskId: string }) => { + await archiveTaskImperative(taskId, queryClient); + toast.success("Task archived"); }; return { archiveTask }; diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index 1a8db1b5a..63dac5268 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -1,3 +1,4 @@ +import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask"; import { useSuspendTask } from "@features/suspension/hooks/useSuspendTask"; import { useArchiveTask } from "@features/tasks/hooks/useArchiveTask"; import { useDeleteTask } from "@features/tasks/hooks/useTasks"; @@ -16,6 +17,7 @@ export function useTaskContextMenu() { const { deleteWithConfirm } = useDeleteTask(); const { archiveTask } = useArchiveTask(); const { suspendTask } = useSuspendTask(); + const { restoreTask } = useRestoreTask(); const showContextMenu = useCallback( async ( @@ -23,20 +25,32 @@ export function useTaskContextMenu() { event: React.MouseEvent, options?: { worktreePath?: string; + folderPath?: string; isPinned?: boolean; + isSuspended?: boolean; onTogglePin?: () => void; + onArchivePrior?: (taskId: string) => void; }, ) => { event.preventDefault(); event.stopPropagation(); - const { worktreePath, isPinned, onTogglePin } = options ?? {}; + const { + worktreePath, + folderPath, + isPinned, + isSuspended, + onTogglePin, + onArchivePrior, + } = options ?? {}; try { const result = await trpcClient.contextMenu.showTaskContextMenu.mutate({ taskTitle: task.title, worktreePath, + folderPath, isPinned, + isSuspended, }); if (!result.action) return; @@ -53,11 +67,18 @@ export function useTaskContextMenu() { toast.success("Task ID copied"); break; case "suspend": - await suspendTask({ taskId: task.id, reason: "manual" }); + if (isSuspended) { + await restoreTask(task.id); + } else { + await suspendTask({ taskId: task.id, reason: "manual" }); + } break; case "archive": await archiveTask({ taskId: task.id }); break; + case "archive-prior": + await onArchivePrior?.(task.id); + break; case "delete": await deleteWithConfirm({ taskId: task.id, @@ -65,12 +86,13 @@ export function useTaskContextMenu() { hasWorktree: !!worktreePath, }); break; - case "external-app": - if (worktreePath) { + case "external-app": { + const effectivePath = worktreePath ?? folderPath; + if (effectivePath) { const workspace = await workspaceApi.get(task.id); await handleExternalAppAction( result.action.action, - worktreePath, + effectivePath, task.title, { workspace, @@ -79,12 +101,13 @@ export function useTaskContextMenu() { ); } break; + } } } catch (error) { log.error("Failed to show context menu", error); } }, - [deleteWithConfirm, archiveTask, suspendTask], + [deleteWithConfirm, archiveTask, suspendTask, restoreTask], ); return {