diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 649ec0c6bac1b..d1a74e4e894fb 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -33,7 +33,12 @@ const state = vi.hoisted(() => ({ projectId: ProjectId; title: string; worktreePath: string | null; - lineage: { parentThreadId: ThreadId | null; sideChat?: boolean }; + lineage: { + parentThreadId: ThreadId | null; + relationshipToParent: "fork" | null; + rootThreadId: ThreadId; + sideChat?: boolean; + }; session: { status: "ready" | "stopped" } | null; latestTurn: null; }[], @@ -269,7 +274,7 @@ beforeEach(() => { projectId: ProjectId.make("project"), title: "Thread", worktreePath: null, - lineage: { parentThreadId: null }, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: target.threadId }, session: null, latestTurn: null, }, @@ -424,6 +429,32 @@ describe("thread action permissions", () => { expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); + it("offers to delete the worktree that only its side chats share", async () => { + state.scopes + .get(secondary)! + .add(AuthOrchestrationOperateScope) + .add(AuthSourceControlWriteScope); + state.threads[0]!.worktreePath = "/worktrees/thread"; + state.threads.push({ + ...state.threads[0]!, + id: ThreadId.make("side-chat"), + lineage: { + parentThreadId: target.threadId, + relationshipToParent: "fork", + rootThreadId: target.threadId, + sideChat: true, + }, + }); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(state.confirm).toHaveBeenCalledOnce(); + expect(state.requests.map((request) => request.action)).toEqual([ + "stopSession", + "delete", + "removeWorktree", + "refreshStatus", + ]); + }); + it("clears the drafts of the side chats deleted with it", async () => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); const parent = state.threads[0]!; @@ -431,9 +462,13 @@ describe("thread action permissions", () => { { ...parent, id: ThreadId.make("side-chat"), - lineage: { parentThreadId: parent.id, sideChat: true }, + lineage: { ...parent.lineage, parentThreadId: parent.id, sideChat: true }, + }, + { + ...parent, + id: ThreadId.make("fork"), + lineage: { ...parent.lineage, parentThreadId: parent.id }, }, - { ...parent, id: ThreadId.make("fork"), lineage: { parentThreadId: parent.id } }, ); expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); expect(state.localEffects).toEqual( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cc9c27770886e..c16f34f23df2d 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -501,10 +501,16 @@ export function useThreadActions() { }), ) : undefined; - const survivingThreads = - deletedIds && deletedIds.size > 0 - ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) - : threads; + // Deleting a thread also deletes its side chats, which share its worktree. + const removedIds = new Set(deletedIds); + for (const entry of threads) { + if (entry.lineage.sideChat === true && entry.lineage.parentThreadId === threadRef.threadId) { + removedIds.add(entry.id); + } + } + const survivingThreads = threads.filter( + (entry) => entry.id === threadRef.threadId || !removedIds.has(entry.id), + ); const environmentConfig = appAtomRegistry .get(environmentServerConfigsAtom) .get(threadRef.environmentId); @@ -522,7 +528,7 @@ export function useThreadActions() { if ( occupants._tag !== "Success" || occupants.value.threadIds.some( - (threadId) => threadId !== threadRef.threadId && deletedIds?.has(threadId) !== true, + (threadId) => threadId !== threadRef.threadId && !removedIds.has(threadId), ) ) { orphanedWorktreePath = null;