From be1c8c4656adad2592d718597018d0df120c58d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 01:30:46 -0800 Subject: [PATCH] Delete projects from the sidebar context menu - Add project right-click Delete action in `Sidebar` and clean up sessions/terminals before removal - Add `DELETE_PROJECT` reducer action to remove the project, its threads, and reset active thread - Add reducer test coverage for project deletion behavior --- apps/web/src/components/Sidebar.tsx | 53 +++++++++++++++++++++++++++++ apps/web/src/store.test.ts | 45 ++++++++++++++++++++++++ apps/web/src/store.ts | 20 +++++++++++ 3 files changed, 118 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce427ab462464259c2149e4d046376eef81d75ce..6f76988ca24af6fbcfe4ebf542aa1cc4b821153c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -333,6 +333,52 @@ export default function Sidebar() { [api, dispatch, removeWorktreeMutation, state.projects, state.threads], ); + const handleProjectContextMenu = useCallback( + async (projectId: string, position: { x: number; y: number }) => { + if (!api) return; + const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + if (clicked !== "delete") return; + + const project = state.projects.find((entry) => entry.id === projectId); + if (!project) return; + + if (isElectron) { + try { + await api.projects.remove({ id: projectId }); + } catch { + return; + } + } + + const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + await Promise.all( + projectThreads.map(async (thread) => { + if (thread.session?.sessionId) { + try { + await api.providers.stopSession({ + sessionId: thread.session.sessionId, + }); + } catch { + // Session may already be stopped + } + } + + try { + await api.terminal.close({ + threadId: thread.id, + deleteHistory: true, + }); + } catch { + // Terminal may already be closed + } + }), + ); + + dispatch({ type: "DELETE_PROJECT", projectId }); + }, + [api, dispatch, state.projects, state.threads], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (!isChatNewShortcut(event, keybindings)) return; @@ -413,6 +459,13 @@ export default function Sidebar() { type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} + onContextMenu={(e) => { + e.preventDefault(); + void handleProjectContextMenu(project.id, { + x: e.clientX, + y: e.clientY, + }); + }} > {project.expanded ? "▼" : "▶"} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 176a6a73fc0d93a11d81b8586c58abe5750291b0..9bc2c3de6695057fdcf34f96e25d5d624b981b9b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { expect(next.activeThreadId).toBe("thread-a"); }); + it("deletes a project and all of its threads", () => { + const state: AppState = { + projects: [ + { + id: "project-1", + name: "One", + cwd: "/tmp/one", + model: "gpt-5.3-codex", + expanded: true, + }, + { + id: "project-2", + name: "Two", + cwd: "/tmp/two", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + makeThread({ + id: "thread-a", + projectId: "project-1", + }), + makeThread({ + id: "thread-b", + projectId: "project-2", + }), + ], + activeThreadId: "thread-a", + runtimeMode: "full-access", + diffOpen: false, + }; + + const next = reducer(state, { + type: "DELETE_PROJECT", + projectId: "project-1", + }); + + expect(next.projects).toHaveLength(1); + expect(next.projects[0]?.id).toBe("project-2"); + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.id).toBe("thread-b"); + expect(next.activeThreadId).toBe("thread-b"); + }); + it("marks the active thread as visited when selected", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3f944601b1cb61f923e671cfca63f1d3be033f09..2f0ad22b6f0d9e058b3d2420073e99a20f300ea3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -28,6 +28,7 @@ type Action = | { type: "ADD_PROJECT"; project: Project } | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "TOGGLE_PROJECT"; projectId: string } + | { type: "DELETE_PROJECT"; projectId: string } | { type: "ADD_THREAD"; thread: Thread } | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { ), }; + case "DELETE_PROJECT": { + const projects = state.projects.filter((project) => project.id !== action.projectId); + if (projects.length === state.projects.length) { + return state; + } + + const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); + const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) + ? state.activeThreadId + : (threads[0]?.id ?? null); + + return { + ...state, + projects, + threads, + activeThreadId, + }; + } + case "ADD_THREAD": { const nextThread = normalizeThreadTerminals({ ...action.thread, -- 2.54.0