From d975ea11e7e879f29b8057b5d1f8aa08fc13f28a Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:56:23 +1100 Subject: [PATCH 1/6] fix(web): deleting an archived thread also clears its draft, staged uploads and terminal state Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useThreadActions.permissions.test.ts | 6 ++++++ apps/web/src/hooks/useThreadActions.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index de5056c4af696..829e0ad900dd5 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -319,6 +319,12 @@ describe("thread action permissions", () => { expect(state.requests).toEqual([ expect.objectContaining({ action: "delete", environmentId: secondary }), ]); + expect(state.localEffects).toEqual([ + "refresh-archive", + "release-uploads", + "clear-draft", + "clear-terminal-ui", + ]); expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); }); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e5eedffc16698..bc7f29c81fb54 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -447,6 +447,9 @@ export function useThreadActions() { if (result._tag === "Success") { expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]); refreshArchivedThreadsForEnvironment(target.environmentId); + releaseComposerDraftUploads(target); + clearComposerDraftForThread(target); + clearTerminalUiState(target); clearDeletedThreadPanelState(target); } return result; From 0b2b7e180ec74e2e0bf6564daf72606bb3e7cc3d Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:36:31 +1100 Subject: [PATCH 2/6] fix(web): deleting a thread clears its side chats' drafts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/SideChatPane.tsx | 4 +++ .../useThreadActions.permissions.test.ts | 26 +++++++++++++++++-- apps/web/src/hooks/useThreadActions.ts | 9 +++++++ 3 files changed, 37 insertions(+), 2 deletions(-) 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..93c77d2318fbb 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({ projectId: ProjectId; title: string; worktreePath: string | null; + lineage: { parentThreadId: ThreadId | null; sideChat?: boolean }; session: { status: "ready" | "stopped" } | null; latestTurn: null; }[], @@ -119,7 +120,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 +141,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 } }), @@ -238,6 +241,7 @@ beforeEach(() => { projectId: ProjectId.make("project"), title: "Thread", worktreePath: null, + lineage: { parentThreadId: null }, session: null, latestTurn: null, }, @@ -348,6 +352,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..a14eda07a7065 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -566,6 +566,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, From 74df5f41d7975b0a3934383680aa583aac0e1524 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 04:37:33 +0000 Subject: [PATCH 3/6] fix(web): clear side chat drafts when deleting archived parents --- .../useThreadActions.permissions.test.ts | 58 ++++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 31 ++++++++++ .../client-runtime/src/state/orchestration.ts | 6 ++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 93c77d2318fbb..b42db1f4e6045 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -24,6 +24,7 @@ function deferred() { } const state = vi.hoisted(() => ({ + serverConfig: { shellThreadFamilies: false }, scopes: new Map>(), threads: [] as { environmentId: EnvironmentId; @@ -35,6 +36,10 @@ const state = vi.hoisted(() => ({ 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>(), @@ -71,15 +76,28 @@ 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" + ? 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( [ @@ -230,6 +248,8 @@ const operations = [ ] as const; beforeEach(() => { + state.serverConfig.shellThreadFamilies = false; + state.familyThreads = []; state.scopes = new Map([ [primary, new Set([AuthOrchestrationOperateScope])], [secondary, new Set()], @@ -312,6 +332,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(); @@ -324,6 +356,16 @@ 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"); }); it("stops before delete and local cleanup when permission is revoked during session stop", async () => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a14eda07a7065..a7fc4aa74c88c 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,38 @@ 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 === "Failure") return family; + 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); 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", From 85de9a75afb57f81bae8143fe1a57a58bf02c556 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:17:56 +1100 Subject: [PATCH 4/6] fix(web): a failed family read no longer blocks deleting a thread Co-Authored-By: Claude Opus 5.5 --- .../hooks/useThreadActions.permissions.test.ts | 16 +++++++++++++++- apps/web/src/hooks/useThreadActions.ts | 3 +-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index b42db1f4e6045..0dea57a8a651f 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -25,6 +25,7 @@ function deferred() { const state = vi.hoisted(() => ({ serverConfig: { shellThreadFamilies: false }, + familyLookupFails: false, scopes: new Map>(), threads: [] as { environmentId: EnvironmentId; @@ -78,7 +79,9 @@ vi.mock("../state/use-atom-command", () => ({ vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: (family: unknown) => async (environmentId: string) => family === "threadFamily" - ? AsyncResult.success({ threads: state.familyThreads }) + ? 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({ @@ -249,6 +252,7 @@ const operations = [ beforeEach(() => { state.serverConfig.shellThreadFamilies = false; + state.familyLookupFails = false; state.familyThreads = []; state.scopes = new Map([ [primary, new Set([AuthOrchestrationOperateScope])], @@ -366,6 +370,16 @@ describe("thread action permissions", () => { ); 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 () => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a7fc4aa74c88c..9b7f0c50bd323 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -456,8 +456,7 @@ export function useThreadActions() { environmentId: target.environmentId, input: { threadId: target.threadId }, }); - if (family._tag === "Failure") return family; - threads = [...family.value.threads]; + if (family._tag === "Success") threads = [...family.value.threads]; } const result = await deleteThreadMutation({ environmentId: target.environmentId, From 7f04b9511e9923116709d09110c537d5711e0390 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:48:38 +0000 Subject: [PATCH 5/6] fix(web): clear an archived thread's drafts once when it is deleted The archived-thread delete path cleared the thread's draft and uploads in the side chat loop and again from the merged #29164 change. Keep the loop, keep the terminal state clear, and assert the exact cleanup list. --- .../hooks/useThreadActions.permissions.test.ts | 16 +++++----------- apps/web/src/hooks/useThreadActions.ts | 2 -- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 7a18f4fd48f9f..3ee0e5c0968b1 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -359,21 +359,15 @@ describe("thread action permissions", () => { expect(state.requests).toEqual([ expect.objectContaining({ action: "delete", environmentId: secondary }), ]); + expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); expect(state.localEffects).toEqual([ + "release-uploads:thread", + "clear-draft:thread", + "release-uploads:side-chat", + "clear-draft:side-chat", "refresh-archive", - "release-uploads", - "clear-draft", "clear-terminal-ui", ]); - 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"); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9194db4e9c817..2252d42cac7a5 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -477,8 +477,6 @@ export function useThreadActions() { } expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]); refreshArchivedThreadsForEnvironment(target.environmentId); - releaseComposerDraftUploads(target); - clearComposerDraftForThread(target); clearTerminalUiState(target); clearDeletedThreadPanelState(target); } From f938455a3eff6b43bfce52ce09d824bf59fd65cd Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:37:09 +0000 Subject: [PATCH 6/6] test(web): drop the stale archived-delete cleanup list main brought back --- apps/web/src/hooks/useThreadActions.permissions.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 86e6a1f4d25a8..58bc33cd0d645 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -371,12 +371,6 @@ describe("thread action permissions", () => { expect(state.requests).toEqual([ expect.objectContaining({ action: "delete", environmentId: secondary }), ]); - expect(state.localEffects).toEqual([ - "refresh-archive", - "release-uploads", - "clear-draft", - "clear-terminal-ui", - ]); expect(threadPanelStatePresence(target)).toEqual(panelStateCleared); expect(state.localEffects).toEqual([ "release-uploads:thread",