diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx index 1a5bd1141b31f..2f430e563925e 100644 --- a/apps/web/src/components/chat/SideChatPane.tsx +++ b/apps/web/src/components/chat/SideChatPane.tsx @@ -14,8 +14,10 @@ import { import { Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { ComposerHandleContext } from "../../composerHandleContext"; import { useClientSettings } from "../../hooks/useSettings"; +import { releaseComposerDraftUploads } from "../../lib/composerDraftUploads"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; import { readLocalApi } from "../../localApi"; import { hostRightPanel, useRightPanelStore } from "../../rightPanelStore"; @@ -111,6 +113,8 @@ export default function SideChatPane(props: { }); setDeleting(false); if (result._tag === "Success") { + releaseComposerDraftUploads(threadRef); + useComposerDraftStore.getState().clearDraftThread(threadRef); useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`); } else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index de5056c4af696..0dea57a8a651f 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -24,6 +24,8 @@ function deferred() { } const state = vi.hoisted(() => ({ + serverConfig: { shellThreadFamilies: false }, + familyLookupFails: false, scopes: new Map>(), threads: [] as { environmentId: EnvironmentId; @@ -31,9 +33,14 @@ const state = vi.hoisted(() => ({ projectId: ProjectId; title: string; worktreePath: string | null; + lineage: { parentThreadId: ThreadId | null; sideChat?: boolean }; session: { status: "ready" | "stopped" } | null; latestTurn: null; }[], + familyThreads: [] as { + id: ThreadId; + lineage: { parentThreadId: ThreadId | null; sideChat?: boolean }; + }[], requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[], localEffects: [] as string[], confirm: vi.fn<(message: string) => Promise>(), @@ -70,15 +77,30 @@ vi.mock("../state/use-atom-command", () => ({ }, })); vi.mock("../state/use-atom-query-runner", () => ({ - useAtomQueryRunner: () => async (environmentId: string) => - state.sessionLookupFails - ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))) - : AsyncResult.success({ - authenticated: true, - scopes: [...(state.scopes.get(environmentId) ?? [])], - auth: { serverUpdateScope: "environment:maintain" }, - }), + useAtomQueryRunner: (family: unknown) => async (environmentId: string) => + family === "threadFamily" + ? state.familyLookupFails + ? AsyncResult.failure(Cause.fail(new Error("Family lookup failed"))) + : AsyncResult.success({ threads: state.familyThreads }) + : state.sessionLookupFails + ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))) + : AsyncResult.success({ + authenticated: true, + scopes: [...(state.scopes.get(environmentId) ?? [])], + auth: { serverUpdateScope: "environment:maintain" }, + }), })); +vi.mock("../state/orchestration", () => ({ + orchestrationEnvironment: { threadFamily: "threadFamily" }, +})); +vi.mock("../state/server", async (importOriginal) => { + const actual = await importOriginal(); + const { Atom } = await import("effect/reactivity"); + return { + ...actual, + environmentServerConfigsAtom: Atom.make(new Map([["secondary", state.serverConfig]])), + }; +}); vi.mock("../state/threads", () => ({ threadEnvironment: Object.fromEntries( [ @@ -119,7 +141,8 @@ vi.mock("../components/Sidebar.logic", () => ({ vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: (select: (store: unknown) => unknown) => select({ - clearDraftThread: () => state.localEffects.push("clear-draft"), + clearDraftThread: (ref: ScopedThreadRef) => + state.localEffects.push(`clear-draft:${ref.threadId}`), clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"), }), })); @@ -139,7 +162,8 @@ vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"), })); vi.mock("../lib/composerDraftUploads", () => ({ - releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"), + releaseComposerDraftUploads: (ref: ScopedThreadRef) => + state.localEffects.push(`release-uploads:${ref.threadId}`), })); vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: state.confirm } }), @@ -227,6 +251,9 @@ const operations = [ ] as const; beforeEach(() => { + state.serverConfig.shellThreadFamilies = false; + state.familyLookupFails = false; + state.familyThreads = []; state.scopes = new Map([ [primary, new Set([AuthOrchestrationOperateScope])], [secondary, new Set()], @@ -238,6 +265,7 @@ beforeEach(() => { projectId: ProjectId.make("project"), title: "Thread", worktreePath: null, + lineage: { parentThreadId: null }, session: null, latestTurn: null, }, @@ -308,6 +336,18 @@ describe("thread action permissions", () => { ); it("deletes an archived thread only with its own environment's grant", async () => { + state.serverConfig.shellThreadFamilies = true; + state.familyThreads = [ + { + id: ThreadId.make("side-chat"), + lineage: { parentThreadId: target.threadId, sideChat: true }, + }, + { id: ThreadId.make("fork"), lineage: { parentThreadId: target.threadId } }, + { + id: ThreadId.make("other-side-chat"), + lineage: { parentThreadId: ThreadId.make("other-parent"), sideChat: true }, + }, + ]; state.threads = []; seedThreadPanelState(target); const actions = useThreadActions(); @@ -320,6 +360,26 @@ describe("thread action permissions", () => { expect.objectContaining({ action: "delete", environmentId: secondary }), ]); expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); + expect(state.localEffects).toEqual( + expect.arrayContaining([ + "release-uploads:thread", + "clear-draft:thread", + "release-uploads:side-chat", + "clear-draft:side-chat", + ]), + ); + expect(state.localEffects).not.toContain("clear-draft:fork"); + expect(state.localEffects).not.toContain("clear-draft:other-side-chat"); + + // The family read only finds drafts to clear; it never blocks the delete. + state.familyLookupFails = true; + state.requests = []; + state.localEffects = []; + expect((await actions.deleteThread(target))._tag).toBe("Success"); + expect(state.requests).toEqual([ + expect.objectContaining({ action: "delete", environmentId: secondary }), + ]); + expect(state.localEffects).toContain("clear-draft:thread"); }); it("stops before delete and local cleanup when permission is revoked during session stop", async () => { @@ -348,6 +408,24 @@ describe("thread action permissions", () => { expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); + it("clears the drafts of the side chats deleted with it", async () => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const parent = state.threads[0]!; + state.threads.push( + { + ...parent, + id: ThreadId.make("side-chat"), + lineage: { parentThreadId: parent.id, sideChat: true }, + }, + { ...parent, id: ThreadId.make("fork"), lineage: { parentThreadId: parent.id } }, + ); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(state.localEffects).toEqual( + expect.arrayContaining(["release-uploads:side-chat", "clear-draft:side-chat"]), + ); + expect(state.localEffects).not.toContain("clear-draft:fork"); + }); + it("does not request worktree removal after its grant is revoked during delete", async () => { state.scopes .get(secondary)! diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e5eedffc16698..9b7f0c50bd323 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -325,6 +325,10 @@ export function useThreadActions() { reportFailure: false, refresh: true, }); + const loadThreadFamily = useAtomQueryRunner(orchestrationEnvironment.threadFamily, { + reportFailure: false, + refresh: true, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -440,11 +444,37 @@ export function useThreadActions() { if (permissionFailure) return permissionFailure; const resolved = resolveThreadTarget(target); if (!resolved) { + let threads = readEnvironmentThreadRefs(target.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [{ id: shell.id, lineage: shell.lineage }]; + }); + if ( + appAtomRegistry.get(environmentServerConfigsAtom).get(target.environmentId) + ?.shellThreadFamilies + ) { + const family = await loadThreadFamily({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (family._tag === "Success") threads = [...family.value.threads]; + } const result = await deleteThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); if (result._tag === "Success") { + for (const ref of [ + target, + ...threads + .filter( + (entry) => + entry.lineage.sideChat === true && entry.lineage.parentThreadId === target.threadId, + ) + .map((entry) => scopeThreadRef(target.environmentId, entry.id)), + ]) { + releaseComposerDraftUploads(ref); + clearComposerDraftForThread(ref); + } expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]); refreshArchivedThreadsForEnvironment(target.environmentId); clearDeletedThreadPanelState(target); @@ -566,6 +596,15 @@ export function useThreadActions() { refreshArchivedThreadsForEnvironment(threadRef.environmentId); releaseComposerDraftUploads(threadRef); clearComposerDraftForThread(threadRef); + // The server deletes the thread's side chats with it. + for (const entry of threads) { + if (entry.lineage.sideChat !== true || entry.lineage.parentThreadId !== threadRef.threadId) { + continue; + } + const sideChatRef = scopeThreadRef(threadRef.environmentId, entry.id); + releaseComposerDraftUploads(sideChatRef); + clearComposerDraftForThread(sideChatRef); + } clearProjectDraftThreadById( scopeProjectRef(threadRef.environmentId, thread.projectId), threadRef, diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 94dc18582a56e..2e465aad9a47a 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -151,6 +151,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:archived-shell-snapshot", tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, }), + threadFamily: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-family-for-delete", + tag: ORCHESTRATION_V2_WS_METHODS.getThreadFamily, + staleTimeMs: 0, + idleTtlMs: 0, + }), // Read right before deleting a worktree, so never served from cache. worktreeThreadIds: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:worktree-thread-ids",