- 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
Unified · Split
| @@ -333,6 +333,52 @@ export default function Sidebar() { · 332 unmodified lines | ||
| 333 | 333 | [api, dispatch, removeWorktreeMutation, state.projects, state.threads], |
| 334 | 334 | ); |
| 335 | 335 | |
| 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 | 382 | useEffect(() => { |
| 337 | 383 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 338 | 384 | if (!isChatNewShortcut(event, keybindings)) return; |
| @@ -413,6 +459,13 @@ export default function Sidebar() { · 74 unmodified lines | ||
| 413 | 459 | type="button" |
| 414 | 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 | 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 | 470 | <span className="text-[10px] text-muted-foreground/70"> |
| 418 | 471 | {project.expanded ? "▼" : "▶"} |
| @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { · 485 unmodified lines | ||
| 486 | 486 | expect(next.activeThreadId).toBe("thread-a"); |
| 487 | 487 | }); |
| 488 | 488 | |
| 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 | 534 | it("marks the active thread as visited when selected", () => { |
| 490 | 535 | const state = makeState( |
| 491 | 536 | makeThread({ |
| @@ -28,6 +28,7 @@ type Action = · 27 unmodified lines | ||
| 28 | 28 | | { type: "ADD_PROJECT"; project: Project } |
| 29 | 29 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 30 | 30 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 31 | + | { type: "DELETE_PROJECT"; projectId: string } | |
| 31 | 32 | | { type: "ADD_THREAD"; thread: Thread } |
| 32 | 33 | | { type: "SET_ACTIVE_THREAD"; threadId: string } |
| 33 | 34 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { · 439 unmodified lines | ||
| 473 | 474 | ), |
| 474 | 475 | }; |
| 475 | 476 | |
| 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 | 496 | case "ADD_THREAD": { |
| 477 | 497 | const nextThread = normalizeThreadTerminals({ |
| 478 | 498 | ...action.thread, |