From aa7c5100c0a6508829bb5a0e6f4e2502268cf05d Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 20 Oct 2026 03:30:05 +0000 Subject: [PATCH 1/5] fix(web): preserve draft edits during attachment preparation --- .../web/src/components/ChatView.logic.test.ts | 140 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 21 ++- apps/web/src/components/ChatView.tsx | 20 +++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8a38f9a102aaa..eebda395a3cae 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,6 @@ +import { useComposerDraftStore, type ComposerThreadDraftState } from "../composerDraftStore"; import { + composerDraftUserContentSignature, findRecordedWorktreeSetup, isThreadWokeUnseen, mergeDirectPreviewAnnotation, @@ -10,6 +12,7 @@ import { threadShellHasStarted, } from "./ChatView.logic"; import { + ComposerContextId, ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, type ServerProvider, @@ -2385,3 +2388,140 @@ describe("isThreadWokeUnseen", () => { expect(isThreadWokeUnseen({ ...base, wokeAt: "not a date" })).toBe(false); }); }); + +describe("composerDraftUserContentSignature", () => { + const target = { + environmentId: EnvironmentId.make("send-preparation"), + threadId: ThreadId.make("draft"), + }; + const empty: ComposerThreadDraftState = { + prompt: "Send this", + images: [], + files: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + modelSelectionByProvider: {}, + activeProvider: null, + runtimeMode: null, + interactionMode: null, + }; + + afterEach(() => useComposerDraftStore.getState().clearComposerContent(target)); + + it("detects prompt edits but accepts upload metadata writes", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(target, "Send this"); + store.addFiles(target, [ + { + type: "file", + id: "report", + name: "report.txt", + mimeType: "text/plain", + sizeBytes: 3, + file: new File(["abc"], "report.txt"), + }, + ]); + const before = composerDraftUserContentSignature(store.getComposerDraft(target)); + store.setFileUpload(target, "report", target.environmentId, "server-upload"); + expect(composerDraftUserContentSignature(store.getComposerDraft(target))).toBe(before); + store.setPrompt(target, "Send this plus my new edit"); + expect(composerDraftUserContentSignature(store.getComposerDraft(target))).not.toBe(before); + expect(store.getComposerDraft(target)?.prompt).toBe("Send this plus my new edit"); + }); + + it("detects attachments and same-ID context edits", () => { + const draft: ComposerThreadDraftState = { + ...empty, + terminalContexts: [ + { + id: "terminal", + threadId: target.threadId, + terminalId: "default", + terminalLabel: "Terminal", + lineStart: 1, + lineEnd: 1, + text: "before", + createdAt: "2026-10-20T00:00:00Z", + }, + ], + reviewComments: [ + { + id: "review", + sectionId: "diff", + sectionTitle: "Changes", + filePath: "file.ts", + startIndex: 0, + endIndex: 0, + rangeLabel: "L1", + text: "before", + diff: "+line", + }, + ], + previewAnnotations: [ + { + id: "preview", + pageUrl: "http://localhost/", + pageTitle: null, + comment: "before", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-10-20T00:00:00Z", + }, + ], + threadContexts: [ + { + version: 1, + kind: "thread", + contextId: ComposerContextId.make("thread-context"), + label: "Thread", + environmentId: target.environmentId, + threadId: target.threadId, + title: "before", + }, + ], + }; + const before = composerDraftUserContentSignature(draft); + const edits: ComposerThreadDraftState[] = [ + { + ...draft, + files: [ + { + type: "file", + id: "new", + name: "new.txt", + mimeType: "text/plain", + sizeBytes: 1, + file: null, + }, + ], + }, + { + ...draft, + terminalContexts: draft.terminalContexts.map((entry) => ({ ...entry, text: "after" })), + }, + { + ...draft, + reviewComments: draft.reviewComments.map((entry) => ({ ...entry, text: "after" })), + }, + { + ...draft, + previewAnnotations: draft.previewAnnotations.map((entry) => ({ + ...entry, + comment: "after", + })), + }, + { + ...draft, + threadContexts: draft.threadContexts.map((entry) => ({ ...entry, title: "after" })), + }, + ]; + for (const edited of edits) expect(composerDraftUserContentSignature(edited)).not.toBe(before); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3e0f7a13e4877..c123d08fc880d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -48,7 +48,11 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type ComposerThreadDraftState, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; @@ -1258,6 +1262,21 @@ export function shouldRefocusComposerOnWindowFocus( ); } +// Upload completion replaces file records without changing the content the user intends to send. +export function composerDraftUserContentSignature( + draft: ComposerThreadDraftState | null | undefined, +): string { + return JSON.stringify({ + prompt: draft?.prompt ?? "", + images: draft?.images.map((image) => image.id) ?? [], + files: draft?.files.map((file) => file.id) ?? [], + terminalContexts: draft?.terminalContexts ?? [], + previewAnnotations: draft?.previewAnnotations ?? [], + reviewComments: draft?.reviewComments ?? [], + threadContexts: draft?.threadContexts ?? [], + }); +} + export interface PlanFollowUpComposerSnapshot { readonly prompt: string; readonly terminalContexts: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5b2f29990c86..3097b2083a231 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/Th import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { + composerDraftUserContentSignature, isThreadWokeUnseen, mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, @@ -6485,6 +6486,15 @@ export default function ChatView(props: ChatViewProps) { }); } + const draftContentForSend = composerDraftUserContentSignature( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ); + const draftChangedDuringPreparation = () => + composerDraftUserContentSignature( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) !== draftContentForSend; + const changedDraftError = + "Draft changed while preparing this message. Review it and send again."; sendInFlightRef.current = true; const sendGeneration = ++composerSendGenerationRef.current; const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); @@ -6549,6 +6559,15 @@ export default function ChatView(props: ChatViewProps) { await dockStarted; } + if (draftChangedDuringPreparation()) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, changedDraftError); + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + return; + } + const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -6615,6 +6634,7 @@ export default function ChatView(props: ChatViewProps) { // fails every selection; the draft restore below still runs. const sendResult = await settlePromise(async () => { const attachments = await turnAttachmentsPromise; + if (draftChangedDuringPreparation()) throw new Error(changedDraftError); const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; if (fileBlockReason !== null) throw new Error(fileBlockReason); const context = buildOutgoingMessageContext( From 2c591e4c383fe375b3762fb28338b34518a38d3d Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 07:10:36 +0000 Subject: [PATCH 2/5] fix(web): preserve environment mention edits while preparing attachments --- apps/web/src/components/ChatView.logic.test.ts | 14 ++++++++++++++ apps/web/src/components/ChatView.logic.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index eb5654aff28f8..48959ab22db88 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1197,6 +1197,7 @@ describe("composerDraftUserContentSignature", () => { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -1281,6 +1282,15 @@ describe("composerDraftUserContentSignature", () => { title: "before", }, ], + environmentContexts: [ + { + version: 1, + kind: "environment", + contextId: ComposerContextId.make("environment-context"), + label: "Before", + environmentId: target.environmentId, + }, + ], }; const before = composerDraftUserContentSignature(draft); const edits: ComposerThreadDraftState[] = [ @@ -1316,6 +1326,10 @@ describe("composerDraftUserContentSignature", () => { ...draft, threadContexts: draft.threadContexts.map((entry) => ({ ...entry, title: "after" })), }, + { + ...draft, + environmentContexts: draft.environmentContexts.map((entry) => ({ ...entry, label: "After" })), + }, ]; for (const edited of edits) expect(composerDraftUserContentSignature(edited)).not.toBe(before); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0e0cce3516842..fb967bd1bc8ae 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1182,6 +1182,7 @@ export function composerDraftUserContentSignature( previewAnnotations: draft?.previewAnnotations ?? [], reviewComments: draft?.reviewComments ?? [], threadContexts: draft?.threadContexts ?? [], + environmentContexts: draft?.environmentContexts ?? [], }); } From ef17dbd9c539fd547c9b8c625e1d9e5621d7bfd4 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 07:11:19 +0000 Subject: [PATCH 3/5] style(web): format environment draft regression --- apps/web/src/components/ChatView.logic.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 48959ab22db88..ebc0057e74eb9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1328,7 +1328,10 @@ describe("composerDraftUserContentSignature", () => { }, { ...draft, - environmentContexts: draft.environmentContexts.map((entry) => ({ ...entry, label: "After" })), + environmentContexts: draft.environmentContexts.map((entry) => ({ + ...entry, + label: "After", + })), }, ]; for (const edited of edits) expect(composerDraftUserContentSignature(edited)).not.toBe(before); From 98eda86c023469e0036d252899bb23eb7966663c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 03:51:03 +0000 Subject: [PATCH 4/5] fix(web): retain drafts with pending attachment preparation --- .../web/src/components/ChatView.logic.test.ts | 34 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 18 ++++++++++ .../src/components/chat/useComposerSend.ts | 5 ++- .../components/chat/useMultipleModelSend.ts | 7 ++-- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a4e3ee855987c..a0415f799d307 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,16 @@ -import { useComposerDraftStore, type ComposerThreadDraftState } from "../composerDraftStore"; +import { + addPreparingComposerImages, + removePreparingComposerImages, +} from "./chat/preparingComposerImages"; +import { pendingDraftWork } from "./chat/pendingDraftWork"; +import { + composerTargetKey, + useComposerDraftStore, + type ComposerThreadDraftState, +} from "../composerDraftStore"; import { composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, boundAttachmentIds, buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, @@ -1300,6 +1310,28 @@ describe("composerDraftUserContentSignature", () => { const before = composerDraftUserContentSignature(store.getComposerDraft(target)); store.setFileUpload(target, "report", target.environmentId, "server-upload"); expect(composerDraftUserContentSignature(store.getComposerDraft(target))).toBe(before); + const key = composerTargetKey(target); + addPreparingComposerImages(key, [ + { id: "compressing", name: "image.png", file: new File(["image"], "image.png") }, + ]); + try { + expect(composerDraftChangedDuringPreparation(target, before)).toBe(true); + expect( + composerDraftChangedDuringPreparation( + { ...target, threadId: ThreadId.make("other") }, + composerDraftUserContentSignature(null), + ), + ).toBe(false); + } finally { + removePreparingComposerImages(key, new Set(["compressing"])); + } + pendingDraftWork.begin(key); + try { + expect(composerDraftChangedDuringPreparation(target, before)).toBe(true); + } finally { + pendingDraftWork.end(key); + } + expect(composerDraftChangedDuringPreparation(target, before)).toBe(false); store.setPrompt(target, "Send this plus my new edit"); expect(composerDraftUserContentSignature(store.getComposerDraft(target))).not.toBe(before); expect(store.getComposerDraft(target)?.prompt).toBe("Send this plus my new edit"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f05d9b607f348..980beaaef7bd9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -71,10 +71,15 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, type ComposerThreadDraftState, + type ComposerThreadTarget, + composerTargetKey, + useComposerDraftStore, type DraftId, type DraftThreadState, } from "../composerDraftStore"; import * as Schema from "effect/Schema"; +import { countPreparingComposerImages } from "./chat/preparingComposerImages"; +import { pendingDraftWork } from "./chat/pendingDraftWork"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; @@ -1188,6 +1193,19 @@ export function composerDraftUserContentSignature( }); } +export function composerDraftChangedDuringPreparation( + target: ComposerThreadTarget, + signature: string, +): boolean { + const key = composerTargetKey(target); + return ( + countPreparingComposerImages(key) > 0 || + pendingDraftWork.has(key) || + composerDraftUserContentSignature(useComposerDraftStore.getState().getComposerDraft(target)) !== + signature + ); +} + export interface PlanFollowUpComposerSnapshot { readonly prompt: string; readonly terminalContexts: ReadonlyArray; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 8837a13e931ee..de3a00b86a47c 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,5 +1,6 @@ import { composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, shouldDockDraftHeroForSubmission, @@ -953,9 +954,7 @@ export function useComposerSend(input: ComposerSendInput) { useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), ); const draftChangedDuringPreparation = () => - composerDraftUserContentSignature( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) !== draftContentForSend; + composerDraftChangedDuringPreparation(composerDraftTarget, draftContentForSend); const changedDraftError = "Draft changed while preparing this message. Review it and send again."; sendInFlightRef.current = true; diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index f82870dfc0bdf..12dc936daea7b 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -27,6 +27,7 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, boundAttachmentIds, type ComposerSendSnapshot, type makeThreadErrorSetter, @@ -127,11 +128,7 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { // fails every selection; the draft restore below still runs. const sendResult = await settlePromise(async () => { const attachments = await attachmentsPromise; - if ( - composerDraftUserContentSignature( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) !== draftContentForSend - ) { + if (composerDraftChangedDuringPreparation(composerDraftTarget, draftContentForSend)) { throw new Error("Draft changed while preparing this message. Review it and send again."); } const fileBlockReason = readFileBlockReason(); From 66c2da6b97824d927669956ea6cef6121b612449 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 04:01:38 +0000 Subject: [PATCH 5/5] fix(web): recheck the draft after redirect uploads finish --- apps/web/src/components/chat/useComposerSend.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index e401f1abe2efe..743c3af997067 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -922,9 +922,11 @@ export function useComposerSend(input: ComposerSendInput) { (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null ? "Retry or remove failed uploads before sending." : null); - if (uploadBlockReason !== null) { + const blockReason = + uploadBlockReason ?? (draftChangedDuringPreparation() ? changedDraftError : null); + if (blockReason !== null) { sendInFlightRef.current = false; - setThreadError(threadIdForSend, uploadBlockReason); + setThreadError(threadIdForSend, blockReason); setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, );