From 1e0acdd73404596f19ee9751348c8b6ca47cb6a5 Mon Sep 17 00:00:00 2001 From: Liam Prunty Date: Sun, 5 Apr 2026 13:29:03 +1000 Subject: [PATCH] Allow deleting projects with chats --- apps/web/src/components/Sidebar.logic.test.ts | 38 ++++ apps/web/src/components/Sidebar.logic.ts | 17 ++ apps/web/src/components/Sidebar.tsx | 175 ++++++++++++++---- apps/web/src/hooks/useThreadActions.ts | 12 +- 4 files changed, 202 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..c7fb51d231 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, + getFallbackThreadIdAfterBulkDelete, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -865,6 +866,43 @@ describe("getFallbackThreadIdAfterDelete", () => { }); }); +describe("getFallbackThreadIdAfterBulkDelete", () => { + it("returns the top remaining non-archived thread across projects", () => { + const fallbackThreadId = getFallbackThreadIdAfterBulkDelete({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + latestUserMessageAt: null, + }, + { + id: ThreadId.makeUnsafe("thread-2"), + projectId: ProjectId.makeUnsafe("project-2"), + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + archivedAt: null, + latestUserMessageAt: null, + }, + { + id: ThreadId.makeUnsafe("thread-3"), + projectId: ProjectId.makeUnsafe("project-3"), + createdAt: "2026-01-03T00:00:00.000Z", + updatedAt: "2026-01-03T00:00:00.000Z", + archivedAt: "2026-01-04T00:00:00.000Z", + latestUserMessageAt: null, + }, + ], + deletedThreadIds: new Set([ThreadId.makeUnsafe("thread-1"), ThreadId.makeUnsafe("thread-3")]), + sortOrder: "updated_at", + }); + + expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-2")); + }); +}); + describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..de5c4696e3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -521,6 +521,23 @@ export function getFallbackThreadIdAfterDelete< ); } +export function getFallbackThreadIdAfterBulkDelete< + T extends Pick & SidebarThreadSortInput, +>(input: { + threads: readonly T[]; + deletedThreadIds: ReadonlySet; + sortOrder: SidebarThreadSortOrder; +}): T["id"] | null { + const { deletedThreadIds, sortOrder, threads } = input; + + return ( + sortThreadsForSidebar( + threads.filter((thread) => !deletedThreadIds.has(thread.id) && thread.archivedAt === null), + sortOrder, + )[0]?.id ?? null + ); +} + export function getProjectSortTimestamp( project: SidebarProject, projectThreads: readonly SidebarThreadSortInput[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..63355fa608 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -112,6 +112,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleSidebarThreadIds, getVisibleThreadsForProject, + getFallbackThreadIdAfterBulkDelete, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -131,6 +132,15 @@ import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -163,6 +173,12 @@ interface PrStatusIndicator { } type ThreadPr = GitStatusResult["pr"]; +interface PendingProjectDelete { + projectId: ProjectId; + projectName: string; + threadIds: ThreadId[]; + threadCount: number; +} function ThreadStatusLabel({ status, @@ -719,6 +735,10 @@ export default function Sidebar() { const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const [pendingProjectDelete, setPendingProjectDelete] = useState( + null, + ); + const [isDeletingProject, setIsDeletingProject] = useState(false); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); @@ -1251,50 +1271,94 @@ export default function Sidebar() { return; } if (clicked !== "delete") return; + const projectThreadIds = [...(threadIdsByProjectId[projectId] ?? [])]; + setPendingProjectDelete({ + projectId, + projectName: project.name, + threadIds: projectThreadIds, + threadCount: projectThreadIds.length, + }); + }, + [copyPathToClipboard, projects, threadIdsByProjectId], + ); - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", + const confirmProjectDelete = useCallback(async () => { + const api = readNativeApi(); + const pendingDelete = pendingProjectDelete; + if (!api || !pendingDelete || isDeletingProject) return; + + setIsDeletingProject(true); + try { + const deletedThreadIds = new Set(pendingDelete.threadIds); + const shouldNavigateAway = + (routeThreadId !== null && deletedThreadIds.has(routeThreadId)) || + (routeThreadId === null && activeDraftThread?.projectId === pendingDelete.projectId); + const fallbackThreadId = shouldNavigateAway + ? getFallbackThreadIdAfterBulkDelete({ + threads: Object.values(sidebarThreadsById), + deletedThreadIds, + sortOrder: appSettings.sidebarThreadSortOrder, + }) + : null; + + for (const threadId of pendingDelete.threadIds) { + await deleteThread(threadId, { + deletedThreadIds, + skipWorktreeConfirm: true, + suppressNavigation: true, }); - return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); + const projectDraftThread = getDraftThreadByProjectId(pendingDelete.projectId); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.threadId); + } + clearProjectDraftThreadId(pendingDelete.projectId); + await api.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: pendingDelete.projectId, + }); + removeFromSelection(pendingDelete.threadIds); + + if (shouldNavigateAway) { + if (fallbackThreadId) { + await navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId }, + replace: true, + }); + } else { + await navigate({ to: "/", replace: true }); } - clearProjectDraftThreadId(projectId); - await api.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); } - }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, - projects, - threadIdsByProjectId, - ], - ); + + setPendingProjectDelete(null); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { projectId: pendingDelete.projectId, error }); + toastManager.add({ + type: "error", + title: `Failed to remove "${pendingDelete.projectName}"`, + description: message, + }); + } finally { + setIsDeletingProject(false); + } + }, [ + activeDraftThread?.projectId, + appSettings.sidebarThreadSortOrder, + clearComposerDraftForThread, + clearProjectDraftThreadId, + deleteThread, + getDraftThreadByProjectId, + isDeletingProject, + navigate, + pendingProjectDelete, + removeFromSelection, + routeThreadId, + sidebarThreadsById, + ]); const projectDnDSensors = useSensors( useSensor(PointerSensor, { @@ -1994,6 +2058,41 @@ export default function Sidebar() { return ( <> + { + if (!open && !isDeletingProject) { + setPendingProjectDelete(null); + } + }} + > + + + + Remove project "{pendingProjectDelete?.projectName}"? + + + {pendingProjectDelete + ? pendingProjectDelete.threadCount > 0 + ? `This permanently deletes the project and its ${pendingProjectDelete.threadCount} ${pendingProjectDelete.threadCount === 1 ? "chat" : "chats"}.` + : "This permanently deletes the project." + : ""} + + + + }> + Cancel + + + + + {isElectron ? ( {wordmark} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d5557b4a96..bd5fd9b5e0 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -65,7 +65,14 @@ export function useThreadActions() { }, []); const deleteThread = useCallback( - async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { + async ( + threadId: ThreadId, + opts: { + deletedThreadIds?: ReadonlySet; + skipWorktreeConfirm?: boolean; + suppressNavigation?: boolean; + } = {}, + ) => { const api = readNativeApi(); if (!api) return; const { projects, threads } = useStore.getState(); @@ -83,6 +90,7 @@ export function useThreadActions() { : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; const shouldDeleteWorktree = + !opts.skipWorktreeConfirm && canDeleteWorktree && (await api.dialogs.confirm( [ @@ -127,7 +135,7 @@ export function useThreadActions() { clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); - if (shouldNavigateToFallback) { + if (shouldNavigateToFallback && !opts.suppressNavigation) { if (fallbackThreadId) { await navigate({ to: "/$threadId",