authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 01:30:46-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 16:35:17-08:00
logbe1c8c4656adad2592d718597018d0df120c58d3
treeb7f94e5fe5bf405e6404f5750fefb7fb20241f06
parentb7fb0b8fed3c523cee60ebd90da8aa4ba5862767

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

3 files changed, 118 insertions(+), 0 deletions(-)

apps/web/src/components/Sidebar.tsx+53
...@@ -333,6 +333,52 @@ export default function Sidebar() {...@@ -333,6 +333,52 @@ export default function Sidebar() {
333 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],333 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
334 );334 );
335335
336 const handleProjectContextMenu = useCallback(
337 async (projectId: string, position: { x: number; y: number }) => {
338 if (!api) return;
339 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
340 if (clicked !== "delete") return;
341
342 const project = state.projects.find((entry) => entry.id === projectId);
343 if (!project) return;
344
345 if (isElectron) {
346 try {
347 await api.projects.remove({ id: projectId });
348 } catch {
349 return;
350 }
351 }
352
353 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
354 await Promise.all(
355 projectThreads.map(async (thread) => {
356 if (thread.session?.sessionId) {
357 try {
358 await api.providers.stopSession({
359 sessionId: thread.session.sessionId,
360 });
361 } catch {
362 // Session may already be stopped
363 }
364 }
365
366 try {
367 await api.terminal.close({
368 threadId: thread.id,
369 deleteHistory: true,
370 });
371 } catch {
372 // Terminal may already be closed
373 }
374 }),
375 );
376
377 dispatch({ type: "DELETE_PROJECT", projectId });
378 },
379 [api, dispatch, state.projects, state.threads],
380 );
381
336 useEffect(() => {382 useEffect(() => {
337 const onWindowKeyDown = (event: KeyboardEvent) => {383 const onWindowKeyDown = (event: KeyboardEvent) => {
338 if (!isChatNewShortcut(event, keybindings)) return;384 if (!isChatNewShortcut(event, keybindings)) return;
...@@ -413,6 +459,13 @@ export default function Sidebar() {...@@ -413,6 +459,13 @@ export default function Sidebar() {
413 type="button"459 type="button"
414 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"460 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"
415 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}461 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
462 onContextMenu={(e) => {
463 e.preventDefault();
464 void handleProjectContextMenu(project.id, {
465 x: e.clientX,
466 y: e.clientY,
467 });
468 }}
416 >469 >
417 <span className="text-[10px] text-muted-foreground/70">470 <span className="text-[10px] text-muted-foreground/70">
418 {project.expanded ? "▼" : "▶"}471 {project.expanded ? "▼" : "▶"}
apps/web/src/store.test.ts+45
...@@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => {...@@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => {
486 expect(next.activeThreadId).toBe("thread-a");486 expect(next.activeThreadId).toBe("thread-a");
487 });487 });
488488
489 it("deletes a project and all of its threads", () => {
490 const state: AppState = {
491 projects: [
492 {
493 id: "project-1",
494 name: "One",
495 cwd: "/tmp/one",
496 model: "gpt-5.3-codex",
497 expanded: true,
498 },
499 {
500 id: "project-2",
501 name: "Two",
502 cwd: "/tmp/two",
503 model: "gpt-5.3-codex",
504 expanded: true,
505 },
506 ],
507 threads: [
508 makeThread({
509 id: "thread-a",
510 projectId: "project-1",
511 }),
512 makeThread({
513 id: "thread-b",
514 projectId: "project-2",
515 }),
516 ],
517 activeThreadId: "thread-a",
518 runtimeMode: "full-access",
519 diffOpen: false,
520 };
521
522 const next = reducer(state, {
523 type: "DELETE_PROJECT",
524 projectId: "project-1",
525 });
526
527 expect(next.projects).toHaveLength(1);
528 expect(next.projects[0]?.id).toBe("project-2");
529 expect(next.threads).toHaveLength(1);
530 expect(next.threads[0]?.id).toBe("thread-b");
531 expect(next.activeThreadId).toBe("thread-b");
532 });
533
489 it("marks the active thread as visited when selected", () => {534 it("marks the active thread as visited when selected", () => {
490 const state = makeState(535 const state = makeState(
491 makeThread({536 makeThread({
apps/web/src/store.ts+20
...@@ -28,6 +28,7 @@ type Action =...@@ -28,6 +28,7 @@ type Action =
28 | { type: "ADD_PROJECT"; project: Project }28 | { type: "ADD_PROJECT"; project: Project }
29 | { type: "SYNC_PROJECTS"; projects: Project[] }29 | { type: "SYNC_PROJECTS"; projects: Project[] }
30 | { type: "TOGGLE_PROJECT"; projectId: string }30 | { type: "TOGGLE_PROJECT"; projectId: string }
31 | { type: "DELETE_PROJECT"; projectId: string }
31 | { type: "ADD_THREAD"; thread: Thread }32 | { type: "ADD_THREAD"; thread: Thread }
32 | { type: "SET_ACTIVE_THREAD"; threadId: string }33 | { type: "SET_ACTIVE_THREAD"; threadId: string }
33 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }34 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
...@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState {
473 ),474 ),
474 };475 };
475476
477 case "DELETE_PROJECT": {
478 const projects = state.projects.filter((project) => project.id !== action.projectId);
479 if (projects.length === state.projects.length) {
480 return state;
481 }
482
483 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
484 const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
485 ? state.activeThreadId
486 : (threads[0]?.id ?? null);
487
488 return {
489 ...state,
490 projects,
491 threads,
492 activeThreadId,
493 };
494 }
495
476 case "ADD_THREAD": {496 case "ADD_THREAD": {
477 const nextThread = normalizeThreadTerminals({497 const nextThread = normalizeThreadTerminals({
478 ...action.thread,498 ...action.thread,