From 31ed3b0736abd30d18d96d809b0100a883fcfabd Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 18 Aug 2026 18:10:03 +0200 Subject: [PATCH 1/8] fix(web): restore a submitted message to the composer when its turn fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a message clears the composer immediately. If the server accepted the turn and it then failed asynchronously — a runtime stream error, a stale pending provider callback, a runtime.error — the failure only surfaced later via session.lastError, long after onSend returned. The inline send-failure path never ran, so the typed text was cleared and lost for good. Keep a snapshot of the submitted text (plus images and contexts) alive until the turn is confirmed accepted-and-clean, and restore it into an empty composer when the turn's session enters an error state. The restore decision is a pure helper (deriveTurnFailureRecoveryAction) keyed on session.status rather than the carried-forward lastError, so a stale prior-turn error cannot trigger a spurious restore. The synchronous and asynchronous failure paths now share one restore routine. Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- .../web/src/components/ChatView.logic.test.ts | 66 +++++++++ apps/web/src/components/ChatView.logic.ts | 43 ++++++ apps/web/src/components/ChatView.tsx | 135 ++++++++++++++++-- 3 files changed, 230 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..2c96ba5774ac 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -18,6 +18,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveTurnFailureRecoveryAction, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, @@ -563,6 +564,71 @@ describe("startNewThreadForProject", () => { }); }); +describe("deriveTurnFailureRecoveryAction", () => { + const base = { + hasPendingSnapshot: true, + preSendTurnId: TurnId.make("turn-1"), + preSendSessionUpdatedAt: "2026-01-01T00:00:00.000Z", + sessionStatus: "running" as const, + sessionUpdatedAt: "2026-01-01T00:00:01.000Z", + latestTurnId: TurnId.make("turn-2"), + latestTurnCompletedAt: null, + composerHasContent: false, + }; + + it("waits while there is no pending snapshot", () => { + expect(deriveTurnFailureRecoveryAction({ ...base, hasPendingSnapshot: false })).toBe("wait"); + }); + + it("restores when the turn fails asynchronously and the composer is empty", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "error", + sessionUpdatedAt: "2026-01-01T00:00:05.000Z", + }), + ).toBe("restore"); + }); + + it("drops without restoring when the user already retyped", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "error", + sessionUpdatedAt: "2026-01-01T00:00:05.000Z", + composerHasContent: true, + }), + ).toBe("drop"); + }); + + it("waits on a stale pre-send error that has not advanced since the send", () => { + // The session was already in "error" when the user sent (lastError is + // carried forward until "ready"); without a fresh session update this must + // not spuriously restore before the new turn has begun. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("wait"); + }); + + it("drops the snapshot once the turn completes cleanly", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "idle", + latestTurnCompletedAt: "2026-01-01T00:00:09.000Z", + }), + ).toBe("drop"); + }); + + it("keeps waiting while the accepted turn is still running", () => { + expect(deriveTurnFailureRecoveryAction(base)).toBe("wait"); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..19216cdb23c0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -520,6 +520,49 @@ export function createLocalDispatchSnapshot( }; } +// A message that was submitted and cleared from the composer must never be +// lost when its turn fails. The synchronous send-failure path (a rejected +// start-turn RPC) restores the composer inline, but a turn that the server +// ACCEPTS and then fails asynchronously — a runtime stream error, a stale +// pending provider callback, a `runtime.error` — surfaces only later via +// `session.lastError`/`session.status`, long after `onSend` returned. This +// decides, from the in-flight snapshot captured at send time, whether that +// later failure should push the text back into an (empty) composer. +// +// The failure signal is `session.status === "error"`, not `lastError`: +// `lastError` is carried forward across a fresh turn until the session next +// reaches "ready", so keying on it would restore a stale prior-turn error. +// `sessionUpdatedAt` must differ from the pre-send value so a session that was +// already in "error" when the user sent (and has not yet transitioned) cannot +// trigger a spurious restore before the new turn has begun. +export function deriveTurnFailureRecoveryAction(input: { + hasPendingSnapshot: boolean; + preSendTurnId: TurnId | null; + preSendSessionUpdatedAt: string | null; + sessionStatus: NonNullable["status"] | null; + sessionUpdatedAt: string | null; + latestTurnId: TurnId | null; + latestTurnCompletedAt: string | null; + composerHasContent: boolean; +}): "restore" | "drop" | "wait" { + if (!input.hasPendingSnapshot) { + return "wait"; + } + const sessionAdvanced = + input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt; + if (input.sessionStatus === "error" && sessionAdvanced) { + // Never clobber text the user has started typing since the send; the + // failed attempt is still in the transcript for them to copy from. + return input.composerHasContent ? "drop" : "restore"; + } + const turnAdvanced = input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId; + if (turnAdvanced && input.latestTurnCompletedAt !== null && input.sessionStatus !== "error") { + // Our turn ran to completion without error: the snapshot is spent. + return "drop"; + } + return "wait"; +} + export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a5b7bb8c601..53a611f0b5ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import { collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + deriveTurnFailureRecoveryAction, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, scheduleEnvironmentReconnectWarning, @@ -1406,6 +1407,21 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + // Snapshot of a submitted message kept alive until its turn is confirmed + // accepted-and-clean, so an asynchronous turn failure (runtime stream error, + // stale pending provider callback, `runtime.error`) can restore the text to + // the composer instead of losing it. See `deriveTurnFailureRecoveryAction`. + const pendingSendRecoveryRef = useRef<{ + threadId: ThreadId; + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; + reviewComments: ReviewCommentContext[]; + preSendTurnId: TurnId | null; + preSendSessionUpdatedAt: string | null; + } | null>(null); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -4906,6 +4922,87 @@ function ChatViewContent(props: ChatViewProps) { ], ); + // Push a captured pre-send snapshot back into the composer. Shared by the + // synchronous send-failure path and the asynchronous turn-failure recovery + // effect so both restore identically. Only ever called when the composer is + // empty, so it never clobbers text the user typed after the send. + const restoreComposerContentFromSnapshot = useCallback( + (snapshot: { + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; + reviewComments: ReviewCommentContext[]; + }) => { + promptRef.current = snapshot.prompt; + const retryComposerImages = snapshot.images.map(cloneComposerImageForRetry); + composerImagesRef.current = retryComposerImages; + composerTerminalContextsRef.current = snapshot.terminalContexts; + composerElementContextsRef.current = snapshot.elementContexts; + setComposerDraftPrompt(composerDraftTarget, snapshot.prompt); + addComposerDraftImages(composerDraftTarget, retryComposerImages); + setComposerDraftTerminalContexts(composerDraftTarget, snapshot.terminalContexts); + setComposerDraftElementContexts(composerDraftTarget, snapshot.elementContexts); + setComposerDraftPreviewAnnotations(composerDraftTarget, snapshot.previewAnnotations); + setComposerDraftReviewComments(composerDraftTarget, snapshot.reviewComments); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length), + prompt: snapshot.prompt, + detectTrigger: true, + }); + }, + [ + addComposerDraftImages, + composerDraftTarget, + setComposerDraftElementContexts, + setComposerDraftPreviewAnnotations, + setComposerDraftPrompt, + setComposerDraftReviewComments, + setComposerDraftTerminalContexts, + ], + ); + + // Recover a submitted message whose turn was accepted but then failed + // asynchronously (runtime stream error, stale pending provider callback, + // `runtime.error`). Those surface via session status/lastError well after + // `onSend` returned, so the inline send-failure path cannot catch them; watch + // the active server thread and restore the pre-send snapshot into an empty + // composer instead of losing the text. + useEffect(() => { + const pending = pendingSendRecoveryRef.current; + if (!pending || !activeServerThread || activeServerThread.id !== pending.threadId) { + return; + } + const session = activeServerThread.session ?? null; + const latestTurn = activeServerThread.latestTurn ?? null; + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + const composerHasContent = + promptRef.current.trim().length > 0 || + composerImagesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerElementContextsRef.current.length > 0 || + (draft?.previewAnnotations.length ?? 0) > 0 || + (draft?.reviewComments.length ?? 0) > 0; + const action = deriveTurnFailureRecoveryAction({ + hasPendingSnapshot: true, + preSendTurnId: pending.preSendTurnId, + preSendSessionUpdatedAt: pending.preSendSessionUpdatedAt, + sessionStatus: session?.status ?? null, + sessionUpdatedAt: session?.updatedAt ?? null, + latestTurnId: latestTurn?.turnId ?? null, + latestTurnCompletedAt: latestTurn?.completedAt ?? null, + composerHasContent, + }); + if (action === "wait") { + return; + } + if (action === "restore") { + restoreComposerContentFromSnapshot(pending); + } + pendingSendRecoveryRef.current = null; + }, [activeServerThread, composerDraftTarget, restoreComposerContentFromSnapshot]); + const onSend = async ( e?: { preventDefault: () => void }, directAnnotation?: { @@ -5093,6 +5190,10 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + // Turn/session markers captured before this send so an async failure can be + // distinguished from a stale prior-turn error (see the recovery effect). + const preSendLatestTurnId = activeThread.latestTurn?.turnId ?? null; + const preSendSessionUpdatedAt = activeThread.session?.updatedAt ?? null; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, @@ -5317,6 +5418,19 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); + // The turn was accepted; keep the composed text recoverable until the + // turn is confirmed clean, so an async failure can restore it. + pendingSendRecoveryRef.current = { + threadId: threadIdForSend, + prompt: promptForSend, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + preSendTurnId: preSendLatestTurnId, + preSendSessionUpdatedAt, + }; } } @@ -5331,6 +5445,7 @@ function ChatViewContent(props: ChatViewProps) { (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 0 ) { + // The turn never started, so the optimistic user message must go too. setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); for (const message of removed) { @@ -5339,21 +5454,13 @@ function ChatViewContent(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerElementContextsRef.current = composerElementContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + restoreComposerContentFromSnapshot({ prompt: promptForSend, - detectTrigger: true, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, }); } if (!isAtomCommandInterrupted(failure)) { From 7cd944958417e09192ab3df3f5d080f92c434c51 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 18 Aug 2026 20:10:57 +0200 Subject: [PATCH 2/8] fix(web): close recovery-snapshot races flagged in review - Arm the pending-send snapshot before the async send RPCs instead of after startThreadTurn resolves. The recovery effect keys off activeServerThread, not the ref, so a failure that lands during the awaits would otherwise run the effect with no snapshot and never restore the composer. A synchronous send failure clears the snapshot again. - Treat a moved latestTurnCompletedAt as clean completion, not just a new turn id. A steered follow-up folds into the running turn and keeps the same id, so the snapshot would otherwise linger and could restore an already-sent message on a later, unrelated session error. Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- .../web/src/components/ChatView.logic.test.ts | 18 ++++++++- apps/web/src/components/ChatView.logic.ts | 13 +++++-- apps/web/src/components/ChatView.tsx | 39 ++++++++++++------- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2c96ba5774ac..b52b6f10ad16 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -568,6 +568,7 @@ describe("deriveTurnFailureRecoveryAction", () => { const base = { hasPendingSnapshot: true, preSendTurnId: TurnId.make("turn-1"), + preSendLatestTurnCompletedAt: null, preSendSessionUpdatedAt: "2026-01-01T00:00:00.000Z", sessionStatus: "running" as const, sessionUpdatedAt: "2026-01-01T00:00:01.000Z", @@ -614,7 +615,7 @@ describe("deriveTurnFailureRecoveryAction", () => { ).toBe("wait"); }); - it("drops the snapshot once the turn completes cleanly", () => { + it("drops the snapshot once a fresh turn completes cleanly", () => { expect( deriveTurnFailureRecoveryAction({ ...base, @@ -624,6 +625,21 @@ describe("deriveTurnFailureRecoveryAction", () => { ).toBe("drop"); }); + it("drops the snapshot after a steered turn completes under the same turn id", () => { + // Steering folds the message into the running turn, so the turn id does not + // change; the moved completion marker is what signals a clean finish. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendLatestTurnCompletedAt: null, + latestTurnCompletedAt: "2026-01-01T00:00:09.000Z", + sessionStatus: "idle", + }), + ).toBe("drop"); + }); + it("keeps waiting while the accepted turn is still running", () => { expect(deriveTurnFailureRecoveryAction(base)).toBe("wait"); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 19216cdb23c0..f0b1fb35d068 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -538,6 +538,7 @@ export function createLocalDispatchSnapshot( export function deriveTurnFailureRecoveryAction(input: { hasPendingSnapshot: boolean; preSendTurnId: TurnId | null; + preSendLatestTurnCompletedAt: string | null; preSendSessionUpdatedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; @@ -555,9 +556,15 @@ export function deriveTurnFailureRecoveryAction(input: { // failed attempt is still in the transcript for them to copy from. return input.composerHasContent ? "drop" : "restore"; } - const turnAdvanced = input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId; - if (turnAdvanced && input.latestTurnCompletedAt !== null && input.sessionStatus !== "error") { - // Our turn ran to completion without error: the snapshot is spent. + // Our turn ran to completion without error, so the snapshot is spent. A fresh + // turn advances `latestTurnId`; a steered follow-up folds into the turn that + // was already running and keeps the same id but moves `completedAt` from its + // pre-send value, so both must count as completion. + const turnCompleted = + input.latestTurnCompletedAt !== null && + (input.latestTurnId !== input.preSendTurnId || + input.latestTurnCompletedAt !== input.preSendLatestTurnCompletedAt); + if (turnCompleted && input.sessionStatus !== "error") { return "drop"; } return "wait"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 53a611f0b5ce..ecdb3e73e523 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1420,6 +1420,7 @@ function ChatViewContent(props: ChatViewProps) { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; preSendTurnId: TurnId | null; + preSendLatestTurnCompletedAt: string | null; preSendSessionUpdatedAt: string | null; } | null>(null); const terminalUiOpenByThreadRef = useRef>({}); @@ -4987,6 +4988,7 @@ function ChatViewContent(props: ChatViewProps) { const action = deriveTurnFailureRecoveryAction({ hasPendingSnapshot: true, preSendTurnId: pending.preSendTurnId, + preSendLatestTurnCompletedAt: pending.preSendLatestTurnCompletedAt, preSendSessionUpdatedAt: pending.preSendSessionUpdatedAt, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, @@ -5193,6 +5195,7 @@ function ChatViewContent(props: ChatViewProps) { // Turn/session markers captured before this send so an async failure can be // distinguished from a stale prior-turn error (see the recovery effect). const preSendLatestTurnId = activeThread.latestTurn?.turnId ?? null; + const preSendLatestTurnCompletedAt = activeThread.latestTurn?.completedAt ?? null; const preSendSessionUpdatedAt = activeThread.session?.updatedAt ?? null; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), @@ -5299,6 +5302,23 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); + // Arm recovery BEFORE the async send RPCs: a turn can be accepted and then + // fail (or fail outright) while those awaits are in flight, and the effect + // that restores the composer keys off `activeServerThread`, not this ref — + // so the snapshot has to already be present when that failure lands. A + // synchronous send failure clears it again below. + pendingSendRecoveryRef.current = { + threadId: threadIdForSend, + prompt: promptForSend, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + preSendTurnId: preSendLatestTurnId, + preSendLatestTurnCompletedAt, + preSendSessionUpdatedAt, + }; let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -5418,19 +5438,8 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); - // The turn was accepted; keep the composed text recoverable until the - // turn is confirmed clean, so an async failure can restore it. - pendingSendRecoveryRef.current = { - threadId: threadIdForSend, - prompt: promptForSend, - images: composerImagesSnapshot, - terminalContexts: composerTerminalContextsSnapshot, - elementContexts: composerElementContextsSnapshot, - previewAnnotations: composerPreviewAnnotationsSnapshot, - reviewComments: composerReviewCommentsSnapshot, - preSendTurnId: preSendLatestTurnId, - preSendSessionUpdatedAt, - }; + // The recovery snapshot armed before these RPCs stays in place so an + // async turn failure can restore it; a clean turn drops it later. } } @@ -5463,6 +5472,10 @@ function ChatViewContent(props: ChatViewProps) { reviewComments: composerReviewCommentsSnapshot, }); } + // The turn never started: drop the snapshot armed before the RPCs so the + // async recovery effect can't later restore this same message again. Only + // this send's snapshot can be here — it was just overwritten above. + pendingSendRecoveryRef.current = null; if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( From e1f8bbde89b28b1d3699679646bb3893e2ca827b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 18 Aug 2026 20:23:27 +0200 Subject: [PATCH 3/8] fix(web): resolve early-arm race and same-ms freshness in recovery Follow-up to review of the recovery snapshot: - Arm the snapshot only after the turn is accepted (not before the send RPCs), and bump a state tick so the recovery effect re-evaluates even if the failing session state already landed during the awaits. Arming early let the effect restore mid-onSend, after which a synchronous failure saw a non-empty composer and left the optimistic user message orphaned in the transcript. - Treat a changed latestTurnId as session advancement in addition to a changed sessionUpdatedAt, so an accept-then-fail that shares the pre-send millisecond timestamp still restores. Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- .../web/src/components/ChatView.logic.test.ts | 21 +++++++- apps/web/src/components/ChatView.logic.ts | 7 ++- apps/web/src/components/ChatView.tsx | 52 ++++++++++--------- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b52b6f10ad16..0e05914a49bf 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -604,17 +604,34 @@ describe("deriveTurnFailureRecoveryAction", () => { it("waits on a stale pre-send error that has not advanced since the send", () => { // The session was already in "error" when the user sent (lastError is - // carried forward until "ready"); without a fresh session update this must - // not spuriously restore before the new turn has begun. + // carried forward until "ready"); with neither the session timestamp nor + // the turn id advancing, this must not spuriously restore before the new + // turn has begun. expect( deriveTurnFailureRecoveryAction({ ...base, + preSendTurnId: TurnId.make("turn-1"), + latestTurnId: TurnId.make("turn-1"), sessionStatus: "error", sessionUpdatedAt: base.preSendSessionUpdatedAt, }), ).toBe("wait"); }); + it("restores an accept-then-fail landing in the same millisecond via a new turn id", () => { + // The failing session.set can share the pre-send millisecond timestamp; a + // freshly advanced turn id is enough to know the failure is this send's. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-1"), + latestTurnId: TurnId.make("turn-2"), + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("restore"); + }); + it("drops the snapshot once a fresh turn completes cleanly", () => { expect( deriveTurnFailureRecoveryAction({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f0b1fb35d068..8c34ee0a1547 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -549,8 +549,13 @@ export function deriveTurnFailureRecoveryAction(input: { if (!input.hasPendingSnapshot) { return "wait"; } + // "Fresh" = the session moved past the pre-send snapshot, so an error now is + // this send's, not a prior one still showing. A changed `sessionUpdatedAt` + // is the usual signal; a changed `latestTurnId` also counts, covering an + // accept-then-fail that lands in the same millisecond as the pre-send state. const sessionAdvanced = - input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt; + (input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt) || + (input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId); if (input.sessionStatus === "error" && sessionAdvanced) { // Never clobber text the user has started typing since the send; the // failed attempt is still in the transcript for them to copy from. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ecdb3e73e523..c324a816ea54 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1423,6 +1423,10 @@ function ChatViewContent(props: ChatViewProps) { preSendLatestTurnCompletedAt: string | null; preSendSessionUpdatedAt: string | null; } | null>(null); + // Bumped after a snapshot is armed so the recovery effect re-evaluates even + // when `activeServerThread` has not changed since it last ran (e.g. the + // failing session state already landed while the send RPCs were awaiting). + const [recoveryReevaluateTick, setRecoveryReevaluateTick] = useState(0); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -5003,7 +5007,12 @@ function ChatViewContent(props: ChatViewProps) { restoreComposerContentFromSnapshot(pending); } pendingSendRecoveryRef.current = null; - }, [activeServerThread, composerDraftTarget, restoreComposerContentFromSnapshot]); + }, [ + activeServerThread, + composerDraftTarget, + recoveryReevaluateTick, + restoreComposerContentFromSnapshot, + ]); const onSend = async ( e?: { preventDefault: () => void }, @@ -5302,23 +5311,6 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - // Arm recovery BEFORE the async send RPCs: a turn can be accepted and then - // fail (or fail outright) while those awaits are in flight, and the effect - // that restores the composer keys off `activeServerThread`, not this ref — - // so the snapshot has to already be present when that failure lands. A - // synchronous send failure clears it again below. - pendingSendRecoveryRef.current = { - threadId: threadIdForSend, - prompt: promptForSend, - images: composerImagesSnapshot, - terminalContexts: composerTerminalContextsSnapshot, - elementContexts: composerElementContextsSnapshot, - previewAnnotations: composerPreviewAnnotationsSnapshot, - reviewComments: composerReviewCommentsSnapshot, - preSendTurnId: preSendLatestTurnId, - preSendLatestTurnCompletedAt, - preSendSessionUpdatedAt, - }; let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -5438,8 +5430,24 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); - // The recovery snapshot armed before these RPCs stays in place so an - // async turn failure can restore it; a clean turn drops it later. + // The turn was accepted; keep the composed text recoverable until the + // turn is confirmed clean, so an async failure can restore it. Bump the + // tick so the recovery effect re-evaluates even if the failing session + // state already arrived while these RPCs were in flight (a ref write + // alone would not re-run the effect). + pendingSendRecoveryRef.current = { + threadId: threadIdForSend, + prompt: promptForSend, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + preSendTurnId: preSendLatestTurnId, + preSendLatestTurnCompletedAt, + preSendSessionUpdatedAt, + }; + setRecoveryReevaluateTick((tick) => tick + 1); } } @@ -5472,10 +5480,6 @@ function ChatViewContent(props: ChatViewProps) { reviewComments: composerReviewCommentsSnapshot, }); } - // The turn never started: drop the snapshot armed before the RPCs so the - // async recovery effect can't later restore this same message again. Only - // this send's snapshot can be here — it was just overwritten above. - pendingSendRecoveryRef.current = null; if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( From d9aa8ff0d9f55141e6b94fede86ce5305de608d2 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 18 Aug 2026 21:25:58 +0200 Subject: [PATCH 4/8] fix(web): supersede the recovery snapshot on every new send A prior turn's recovery snapshot stayed live while a follow-up or steer send cleared the composer and awaited its RPCs. If the session hit error in that window the effect restored the old snapshot into the now-empty composer, and a later synchronous failure for the new send then skipped restore (composer no longer empty), losing the newly typed text. Drop any pending snapshot at each point onSend takes over the composer (main send, plan follow-up, standalone slash command); the main send re-arms its own snapshot once its turn is accepted. Only the most recent send is recoverable, which matches the single-snapshot model. Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c324a816ea54..0451e58b6363 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5127,6 +5127,7 @@ function ChatViewContent(props: ChatViewProps) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5149,6 +5150,7 @@ function ChatViewContent(props: ChatViewProps) { : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5308,6 +5310,11 @@ function ChatViewContent(props: ChatViewProps) { }), ); } + // A new send supersedes any earlier recovery snapshot: the previous turn's + // text must not restore into this send's freshly cleared composer (which + // would then be treated as non-empty and drop this send's own text on a + // later failure). This send re-arms its own snapshot on accept below. + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); From 2af91f514bbaa4875021f03602dc8faeda0fec50 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 26 Aug 2026 00:59:20 +0200 Subject: [PATCH 5/8] fix(web): restore composer on a steered same-ms turn failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A steered send folds its message into the already-running turn, so the turn id does not change. When such a turn is accepted and then fails in the same millisecond as the pre-send snapshot, neither `sessionUpdatedAt` nor `latestTurnId` advances, so `deriveTurnFailureRecoveryAction` never took the error-restore branch, and the "error" status also blocked the completion `drop` — leaving the snapshot stuck on `wait` and the cleared composer text lost on the next send. Treat the session status crossing from a non-error pre-send value into "error" as its own freshness signal. A stale prior-turn error was already "error" at send time, so it is excluded and cannot spuriously restore. Thread the pre-send session status through the recovery snapshot and add regression tests for the steered same-ms failure (restore and drop). Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- .../web/src/components/ChatView.logic.test.ts | 33 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 21 +++++++++--- apps/web/src/components/ChatView.tsx | 4 +++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b5b3e6f95cef..0f4b60c90f8b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -734,6 +734,7 @@ describe("deriveTurnFailureRecoveryAction", () => { preSendTurnId: TurnId.make("turn-1"), preSendLatestTurnCompletedAt: null, preSendSessionUpdatedAt: "2026-01-01T00:00:00.000Z", + preSendSessionStatus: "running" as const, sessionStatus: "running" as const, sessionUpdatedAt: "2026-01-01T00:00:01.000Z", latestTurnId: TurnId.make("turn-2"), @@ -776,6 +777,7 @@ describe("deriveTurnFailureRecoveryAction", () => { ...base, preSendTurnId: TurnId.make("turn-1"), latestTurnId: TurnId.make("turn-1"), + preSendSessionStatus: "error", sessionStatus: "error", sessionUpdatedAt: base.preSendSessionUpdatedAt, }), @@ -806,6 +808,37 @@ describe("deriveTurnFailureRecoveryAction", () => { ).toBe("drop"); }); + it("restores a steered accept-then-fail that reuses the pre-send turn id and millisecond", () => { + // Steering folds the message into the running turn, so the turn id does not + // change; a same-millisecond failure also leaves `sessionUpdatedAt` equal to + // the pre-send value. The status crossing from a non-error pre-send into + // "error" is what proves this send's turn failed, so the snapshot restores. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendSessionStatus: "running", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("restore"); + }); + + it("drops a steered same-ms failure without clobbering text the user retyped", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendSessionStatus: "running", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + composerHasContent: true, + }), + ).toBe("drop"); + }); + it("drops the snapshot after a steered turn completes under the same turn id", () => { // Steering folds the message into the running turn, so the turn id does not // change; the moved completion marker is what signals a clean finish. diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7ba42fe345ce..f7f200394791 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -624,14 +624,19 @@ export function createLocalDispatchSnapshot( // The failure signal is `session.status === "error"`, not `lastError`: // `lastError` is carried forward across a fresh turn until the session next // reaches "ready", so keying on it would restore a stale prior-turn error. -// `sessionUpdatedAt` must differ from the pre-send value so a session that was -// already in "error" when the user sent (and has not yet transitioned) cannot -// trigger a spurious restore before the new turn has begun. +// Freshness (that the error is this send's, not a prior one still showing) is +// established by any of: a changed `sessionUpdatedAt`, a changed `latestTurnId`, +// or the pre-send session status not yet being "error". The last covers a +// steered send, which keeps the running turn's id and can reuse the pre-send +// millisecond, so neither of the first two advances on an accept-then-fail. A +// session that was already "error" when the user sent (and has not transitioned) +// is excluded from all three, so it cannot trigger a spurious restore. export function deriveTurnFailureRecoveryAction(input: { hasPendingSnapshot: boolean; preSendTurnId: TurnId | null; preSendLatestTurnCompletedAt: string | null; preSendSessionUpdatedAt: string | null; + preSendSessionStatus: NonNullable["status"] | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; latestTurnId: TurnId | null; @@ -648,7 +653,15 @@ export function deriveTurnFailureRecoveryAction(input: { const sessionAdvanced = (input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt) || (input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId); - if (input.sessionStatus === "error" && sessionAdvanced) { + // A steered send folds into the already-running turn, so its turn id does not + // change; if the accept-then-fail also reuses the pre-send millisecond, + // neither `sessionAdvanced` clause fires. The session status crossing into + // "error" from a non-error pre-send value is itself proof this send's turn + // failed: a stale prior-turn error would already have been "error" at send + // time (the case guarded below), so it cannot masquerade as this transition. + const failedSincePreSend = + input.preSendSessionStatus !== "error" && input.sessionStatus === "error"; + if (input.sessionStatus === "error" && (sessionAdvanced || failedSincePreSend)) { // Never clobber text the user has started typing since the send; the // failed attempt is still in the transcript for them to copy from. return input.composerHasContent ? "drop" : "restore"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d9dac7c4e6d4..e3f2591d5048 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1506,6 +1506,7 @@ function ChatViewContent(props: ChatViewProps) { preSendTurnId: TurnId | null; preSendLatestTurnCompletedAt: string | null; preSendSessionUpdatedAt: string | null; + preSendSessionStatus: NonNullable["status"] | null; } | null>(null); // Bumped after a snapshot is armed so the recovery effect re-evaluates even // when `activeServerThread` has not changed since it last ran (e.g. the @@ -5415,6 +5416,7 @@ function ChatViewContent(props: ChatViewProps) { preSendTurnId: pending.preSendTurnId, preSendLatestTurnCompletedAt: pending.preSendLatestTurnCompletedAt, preSendSessionUpdatedAt: pending.preSendSessionUpdatedAt, + preSendSessionStatus: pending.preSendSessionStatus, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, latestTurnId: latestTurn?.turnId ?? null, @@ -5726,6 +5728,7 @@ function ChatViewContent(props: ChatViewProps) { const preSendLatestTurnId = activeThread.latestTurn?.turnId ?? null; const preSendLatestTurnCompletedAt = activeThread.latestTurn?.completedAt ?? null; const preSendSessionUpdatedAt = activeThread.session?.updatedAt ?? null; + const preSendSessionStatus = activeThread.session?.status ?? null; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, @@ -6021,6 +6024,7 @@ function ChatViewContent(props: ChatViewProps) { preSendTurnId: preSendLatestTurnId, preSendLatestTurnCompletedAt, preSendSessionUpdatedAt, + preSendSessionStatus, }; setRecoveryReevaluateTick((tick) => tick + 1); if (backgroundThreadRef) { From 033ee2118de3419a3a8febad342b8abb68e13e02 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 1 Sep 2026 18:14:16 +0200 Subject: [PATCH 6/8] fix(web): don't lose attachments or misfire recovery on turn-failure restore Two issues in the accepted-turn-then-async-failure recovery path: - The recovery snapshot kept each file's uploadedAttachmentId even though the turn's upload was released the moment it was accepted. On restore the upload queue verified the dangling id, found it deleted, and silently turned the row into a needs-reattach marker; a byte-less file (hydrated from persistence) lost its content outright. The snapshot now strips the released upload reference, so files with bytes re-upload cleanly and byte-less files honestly surface as needs-reattach immediately. - The "failed since pre-send" freshness fallback fired for any non-error pre-send status crossing into "error", so a prior turn's error that merely landed during our send RPCs could masquerade as this send's failure and clobber the composer. That fallback only ever mattered for a steered send (one folded into an already-running turn), so it is now gated on an in-progress pre-send status; a fresh send's own failure still advances the turn id and is caught by the existing freshness check. Both are covered by focused tests in ChatView.logic.test.ts. Co-Authored-By: Claude Opus 4.8 --- .../web/src/components/ChatView.logic.test.ts | 51 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 41 ++++++++++++--- apps/web/src/components/ChatView.tsx | 8 ++- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a3949d202405..a6ea10dab72e 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import { composerFileNeedsReattach, type ComposerFileAttachment } from "../composerDraftStore"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -17,6 +18,7 @@ import { buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, + clearComposerFileUploadReference, createLocalDispatchSnapshot, deriveComposerSendState, deriveTurnFailureRecoveryAction, @@ -913,6 +915,24 @@ describe("deriveTurnFailureRecoveryAction", () => { ).toBe("wait"); }); + it("waits when a prior turn's error lands during our awaits and no turn was in progress at send", () => { + // The session was idle when the user sent (no turn to steer into), so a + // freshly-minted turn's own failure would advance `latestTurnId`. An "error" + // that appears with neither the turn id nor the timestamp advancing is a + // prior turn's error surfacing during our RPC awaits, not this send's — it + // must not masquerade as this send's failure and clobber the composer. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-1"), + latestTurnId: TurnId.make("turn-1"), + preSendSessionStatus: "idle", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("wait"); + }); + it("restores an accept-then-fail landing in the same millisecond via a new turn id", () => { // The failing session.set can share the pre-send millisecond timestamp; a // freshly advanced turn id is enough to know the failure is this send's. @@ -988,6 +1008,37 @@ describe("deriveTurnFailureRecoveryAction", () => { }); }); +describe("clearComposerFileUploadReference", () => { + const fileWithBytes: ComposerFileAttachment = { + type: "file", + id: "file-1", + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 10, + file: new File([new Uint8Array([1, 2, 3])], "notes.pdf", { type: "application/pdf" }), + uploadedAttachmentId: "pending-upload-1", + uploadEnvironmentId: EnvironmentId.make("env-1"), + }; + + it("drops the released upload reference so a file with bytes re-uploads cleanly", () => { + const cleared = clearComposerFileUploadReference(fileWithBytes); + expect(cleared.uploadedAttachmentId).toBeUndefined(); + expect(cleared.uploadEnvironmentId).toBeUndefined(); + // Bytes survive, so it is not a needs-reattach placeholder. + expect(cleared.file).toBe(fileWithBytes.file); + expect(composerFileNeedsReattach(cleared)).toBe(false); + }); + + it("turns a byte-less file into an honest needs-reattach row", () => { + // A file hydrated from persistence has no local bytes; once its released + // upload id is stripped it must surface as needs-reattach immediately + // instead of appearing attached and silently failing verification. + const byteless: ComposerFileAttachment = { ...fileWithBytes, file: null }; + expect(composerFileNeedsReattach(byteless)).toBe(false); + expect(composerFileNeedsReattach(clearComposerFileUploadReference(byteless))).toBe(true); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3391a9995a79..089bf820c61b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -24,7 +24,11 @@ import { type Thread, type ThreadShell, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerFileAttachment, + type ComposerImageAttachment, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -386,6 +390,25 @@ export function resolveBackgroundDraftWorkspaceOptions(input: { }; } +/** + * Drops a file's server-side upload reference so a recovery snapshot does not + * point at an upload that was already released when the turn was accepted. On + * restore, a file that still has local bytes re-uploads cleanly, and a byte-less + * file (hydrated from persistence, `file: null`) honestly surfaces as + * needs-reattach instead of appearing attached and then silently failing the + * upload queue's verification against the deleted upload. + */ +export function clearComposerFileUploadReference( + file: ComposerFileAttachment, +): ComposerFileAttachment { + const { + uploadedAttachmentId: _uploadedAttachmentId, + uploadEnvironmentId: _uploadEnvironmentId, + ...rest + } = file; + return rest; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { @@ -713,12 +736,16 @@ export function deriveTurnFailureRecoveryAction(input: { (input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId); // A steered send folds into the already-running turn, so its turn id does not // change; if the accept-then-fail also reuses the pre-send millisecond, - // neither `sessionAdvanced` clause fires. The session status crossing into - // "error" from a non-error pre-send value is itself proof this send's turn - // failed: a stale prior-turn error would already have been "error" at send - // time (the case guarded below), so it cannot masquerade as this transition. - const failedSincePreSend = - input.preSendSessionStatus !== "error" && input.sessionStatus === "error"; + // neither `sessionAdvanced` clause fires. A crossing into "error" then stands + // in as the freshness signal — but only for a steered send, i.e. one made + // while a turn was already in progress ("starting"/"running"). A fresh send + // mints a new turn, so its failure advances `latestTurnId` and is caught by + // `sessionAdvanced` above; restricting this fallback to an in-progress + // pre-send state stops a prior turn's error that merely lands during our + // awaits (pre-send read as idle/ready) from masquerading as this send's. + const preSendTurnWasInProgress = + input.preSendSessionStatus === "starting" || input.preSendSessionStatus === "running"; + const failedSincePreSend = preSendTurnWasInProgress && input.sessionStatus === "error"; if (input.sessionStatus === "error" && (sessionAdvanced || failedSincePreSend)) { // Never clobber text the user has started typing since the send; the // failed attempt is still in the transcript for them to copy from. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f30daafbbe15..be63f7cb65c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,6 +365,7 @@ import { LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, + clearComposerFileUploadReference, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, @@ -6323,7 +6324,12 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, prompt: promptForSend, images: composerImagesSnapshot, - files: composerFilesSnapshot, + // The uploads were just released, so the snapshot must not keep their + // now-dangling ids: files with bytes re-upload on restore, byte-less + // files honestly surface as needs-reattach. Images always carry bytes. + files: turnUsesAttachmentUploads + ? composerFilesSnapshot.map(clearComposerFileUploadReference) + : composerFilesSnapshot, terminalContexts: composerTerminalContextsSnapshot, elementContexts: composerElementContextsSnapshot, previewAnnotations: composerPreviewAnnotationsSnapshot, From cbfb03b0e57fb8dfddbc6d3adb9aa53e5a24d52b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 1 Sep 2026 18:36:51 +0200 Subject: [PATCH 7/8] fix(web): scope turn-failure recovery guard by environment The recovery effect matched the armed snapshot on bare threadId. Thread ids are only unique within an environment, so navigating to the same id in another environment could restore this send's prompt and attachments into a different thread's composer. Store the environmentId in the snapshot and compare it in the guard, matching how the rest of the component keys thread identity. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index be63f7cb65c7..ccbc9a652728 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1551,6 +1551,7 @@ function ChatViewContent(props: ChatViewProps) { // stale pending provider callback, `runtime.error`) can restore the text to // the composer instead of losing it. See `deriveTurnFailureRecoveryAction`. const pendingSendRecoveryRef = useRef<{ + environmentId: EnvironmentId; threadId: ThreadId; prompt: string; images: ComposerImageAttachment[]; @@ -5613,7 +5614,15 @@ function ChatViewContent(props: ChatViewProps) { // composer instead of losing the text. useEffect(() => { const pending = pendingSendRecoveryRef.current; - if (!pending || !activeServerThread || activeServerThread.id !== pending.threadId) { + if ( + !pending || + !activeServerThread || + // Thread ids are only unique within an environment, so scope the guard by + // environment too: otherwise the same id in another environment would + // restore this send's content into a different thread's composer. + activeServerThread.environmentId !== pending.environmentId || + activeServerThread.id !== pending.threadId + ) { return; } const session = activeServerThread.session ?? null; @@ -6321,6 +6330,7 @@ function ChatViewContent(props: ChatViewProps) { // state already arrived while these RPCs were in flight (a ref write // alone would not re-run the effect). pendingSendRecoveryRef.current = { + environmentId: activeThread.environmentId, threadId: threadIdForSend, prompt: promptForSend, images: composerImagesSnapshot, From 246f466278a2fceb1011441b85a6ba0facef59e5 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 3 Sep 2026 17:25:13 +0200 Subject: [PATCH 8/8] fix(web): key turn-failure recovery content check to the thread's draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery effect read 'has the user retyped?' from the imperative composer refs, which stay on the previously viewed thread until child effects resync. Navigating back to a failed turn could then misjudge this thread's composer — dropping the snapshot as if the user retyped, or overwriting the stored draft as if the composer were empty. Read it from the draft store keyed by this thread's target via composerDraftHasUserContent instead. --- apps/web/src/components/ChatView.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44f4ebd6d18d..aa42dc0ec4fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6023,15 +6023,13 @@ function ChatViewContent(props: ChatViewProps) { } const session = activeServerThread.session ?? null; const latestTurn = activeServerThread.latestTurn ?? null; + // Read "has the user retyped?" from the draft store keyed by this thread's + // target, not the imperative composer refs: those still hold the previously + // viewed thread's content until child effects resync, so on navigating back + // to a failed turn they would misjudge whether *this* thread's composer has + // content and wrongly drop or apply the recovery snapshot. const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); - const composerHasContent = - promptRef.current.trim().length > 0 || - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerElementContextsRef.current.length > 0 || - (draft?.previewAnnotations.length ?? 0) > 0 || - (draft?.reviewComments.length ?? 0) > 0; + const composerHasContent = composerDraftHasUserContent(draft); const action = deriveTurnFailureRecoveryAction({ hasPendingSnapshot: true, preSendTurnId: pending.preSendTurnId,