Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/code/src/main/services/context-menu/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 }),
]);
Expand Down
34 changes: 26 additions & 8 deletions apps/code/src/main/services/context-menu/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,25 +104,43 @@ export class ContextMenuService {
async showTaskContextMenu(
input: TaskContextMenuInput,
): Promise<TaskContextMenuResult> {
const { worktreePath, isPinned } = input;
const { worktreePath, folderPath, isPinned, isSuspended } = input;
const { apps, lastUsedAppId } = await this.getExternalAppsData();
const hasPath = worktreePath || folderPath;

return this.showMenu<TaskAction>([
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<TaskAction>(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",
},
},
),
]);
}

Expand Down
67 changes: 64 additions & 3 deletions apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -116,6 +120,11 @@ function SidebarMenuComponent() {
}
};

const allSidebarTasks = [
...sidebarData.pinnedTasks,
...sidebarData.flatTasks,
];

const handleTaskContextMenu = (
taskId: string,
e: React.MouseEvent,
Expand All @@ -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,
});
}
};
Expand All @@ -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(
Expand Down
133 changes: 70 additions & 63 deletions apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string[]>(
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<ArchivedTask[]>(
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<string[]>(
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<ArchivedTask[]>(
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<string[]>(
trpc.archive.archivedTaskIds.queryKey(),
(old) => (old ? old.filter((id) => id !== taskId) : []),
);
queryClient.setQueryData<ArchivedTask[]>(
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 };
Expand Down
Loading
Loading