From 7e08e5435ca2567e2dbb193f5d0f95892062461d Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:55:09 +0200 Subject: [PATCH 1/2] fix(web): release the composer before background worktree setup --- apps/web/src/components/ChatView.tsx | 138 ++++++++++++------- apps/web/src/composerDraftStore.test.ts | 40 ++++++ apps/web/src/composerDraftStore.ts | 17 +++ apps/web/src/routes/_chat.draft.$draftId.tsx | 1 + 4 files changed, 144 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e43bd23e130b..d2fc32e5d535 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -288,6 +288,7 @@ import { type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, + restoreFailedBackgroundDraftThread, useComposerDraftStore, DraftId, } from "../composerDraftStore"; @@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) { } let turnStartSucceeded = false; + let backgroundDraftOpened = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) { if (backgroundThreadRef) { beginBackgroundDraftSubmissionByRef(backgroundThreadRef); } - const startResult = await startThreadTurn({ + const startPromise = startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - if (startResult._tag === "Failure") { - if (backgroundThreadRef) { + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + backgroundDraftOpened = Boolean( + await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ), + ); + } catch (error) { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Could not open a fresh composer", + description: error instanceof Error ? error.message : undefined, + }), + ); } + } + const startResult = await startPromise; + if (startResult._tag === "Failure") { failure = startResult; } else { turnStartSucceeded = true; @@ -7991,56 +8015,42 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); - try { - const nextDraft = await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ); - if (nextDraft) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - } - } catch (error) { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - resetLocalDispatch(); + if (backgroundDraftOpened) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); toastManager.add( stackedThreadToast({ - type: "warning", - title: "Task started in the background", - description: - error instanceof Error - ? `Could not open a fresh composer: ${error.message}` - : "Could not open a fresh composer.", + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, }), ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); } } } } if (failure !== null) { + if (resolvedSubmissionIntent === "background" && draftId && draftThread) { + restoreFailedBackgroundDraftThread( + draftId, + draftThread, + wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) + ? newThreadId() + : threadIdForSend, + ); + clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); + } if (queuedMessage) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -8059,14 +8069,18 @@ export default function ChatView(props: ChatViewProps) { }); } } else if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 + backgroundDraftOpened + ? !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) + : promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) + ?.previewAnnotations.length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -8095,7 +8109,12 @@ export default function ChatView(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); - if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + if ( + resolvedSubmissionIntent !== "background" && + isLocalDraftThread && + draftId && + wasBootstrapThreadDeleted(error) + ) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { setLogicalProjectDraftThreadId( @@ -8113,6 +8132,21 @@ export default function ChatView(props: ChatViewProps) { threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", ); + if (backgroundDraftOpened && draftId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Background task failed", + description: error instanceof Error ? error.message : "Failed to send message.", + actionProps: { + children: "Open draft", + onClick: () => { + void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + }, + }), + ); + } } } sendInFlightRef.current = false; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 7b44b1b71128..05f8cb6ecf47 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -70,6 +70,7 @@ import { composerDraftHasUserContent, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, + restoreFailedBackgroundDraftThread, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1337,6 +1338,45 @@ describe("composerDraftStore project draft thread mapping", () => { ); }); + it.each([false, true])( + "restores a failed background draft without replacing the next draft (finalized: %s)", + (finalized) => { + const store = useComposerDraftStore.getState(); + const nextDraftId = DraftId.make("next-draft"); + const retryThreadId = ThreadId.make("retry-thread"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "main", + envMode: "worktree", + startFromOrigin: true, + }); + const sentDraft = store.getDraftSession(draftId)!; + markPromotedDraftThreadByRef(threadRef); + store.setProjectDraftThreadId(projectRef, nextDraftId, { + threadId: ThreadId.make("next-thread"), + }); + store.setPrompt(nextDraftId, "My next task"); + const nextDraft = store.getDraftSession(nextDraftId); + if (finalized) finalizePromotedDraftThreadByRef(threadRef); + + restoreFailedBackgroundDraftThread(draftId, sentDraft, retryThreadId); + store.setPrompt(draftId, "Retry the first task"); + + expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId); + expect(store.getDraftSession(nextDraftId)).toBe(nextDraft); + expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("My next task"); + expect(store.getDraftSession(draftId)).toMatchObject({ + threadId: retryThreadId, + promotedTo: null, + branch: "main", + envMode: "worktree", + startFromOrigin: true, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("Retry the first task"); + }, + ); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e8c60911caa5..a5e333f218d6 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } +export function restoreFailedBackgroundDraftThread( + draftId: DraftId, + draftThread: DraftThreadState, + threadId: ThreadId, +): void { + useComposerDraftStore.setState((state) => ({ + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [draftId]: { + ...draftThread, + threadId, + promotedTo: null, + }, + }, + })); +} + export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 04cdf3ce8c9b..9d393f27e0bd 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -78,6 +78,7 @@ function DraftChatThreadRouteView() { return ( Date: Tue, 15 Sep 2026 14:17:32 +0200 Subject: [PATCH 2/2] fix(web): clean up background drafts after navigation --- apps/web/src/components/ChatView.tsx | 8 +++++--- apps/web/src/composerDraftStore.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2fc32e5d535..d0d05146c489 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8015,8 +8015,12 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - if (backgroundDraftOpened) { + if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { finalizePromotedDraftThreadByRef(backgroundThreadRef); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + if (backgroundDraftOpened) { toastManager.add( stackedThreadToast({ type: "success", @@ -8033,8 +8037,6 @@ export default function ChatView(props: ChatViewProps) { }, }), ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); } } } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 05f8cb6ecf47..92672348f97e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -66,6 +66,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, + beginBackgroundDraftSubmissionByRef, clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, @@ -1628,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup"); }); + it("cleans up a completed background draft without replacing the active draft", () => { + const store = useComposerDraftStore.getState(); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const nextDraftId = DraftId.make("next-draft"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + beginBackgroundDraftSubmissionByRef(threadRef); + markPromotedDraftThreadByRef(threadRef); + store.setProjectDraftThreadId(projectRef, nextDraftId, { + threadId: ThreadId.make("next-thread"), + }); + store.setPrompt(nextDraftId, "Keep my next task"); + + finalizePromotedDraftThreadByRef(threadRef); + + expect(store.getDraftSession(draftId)).toBeNull(); + expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId); + expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("Keep my next task"); + expect( + useComposerDraftStore.getState().backgroundSubmissionThreadKeys[scopedThreadKey(threadRef)], + ).toBeUndefined(); + }); + it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId });