| author | |
| committer | |
| log | be1c8c4656adad2592d718597018d0df120c58d3 |
| tree | b7f94e5fe5bf405e6404f5750fefb7fb20241f06 |
| parent | b7fb0b8fed3c523cee60ebd90da8aa4ba5862767 |
- 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 behavior3 files changed, 118 insertions(+), 0 deletions(-)
apps/web/src/components/Sidebar.tsx+53| ... | ... | @@ -333,6 +333,52 @@ export default function Sidebar() { |
| 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() { |
| 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 ? "▼" : "▶"} |
apps/web/src/store.test.ts+45| ... | ... | @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { |
| 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({ |
apps/web/src/store.ts+20| ... | ... | @@ -28,6 +28,7 @@ type Action = |
| 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 { |
| 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, |