| 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,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 | ); |
| 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 | 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 | }); |
| 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 | 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 | }; |
| 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 | case "ADD_THREAD": { | 496 | case "ADD_THREAD": { |
| 477 | const nextThread = normalizeThreadTerminals({ | 497 | const nextThread = normalizeThreadTerminals({ |
| 478 | ...action.thread, | 498 | ...action.thread, |