diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c1f68878e9644..06c01a222c7d0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,17 @@ import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { + addPreparingComposerImages, + removePreparingComposerImages, +} from "./chat/preparingComposerImages"; +import { pendingDraftWork } from "./chat/pendingDraftWork"; +import { + composerTargetKey, + useComposerDraftStore, + type ComposerThreadDraftState, +} from "../composerDraftStore"; +import { + composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, boundAttachmentIds, buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, @@ -14,6 +26,7 @@ import { deriveProviderInstanceEntries } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; import { terminalContextReference } from "../lib/composerContextRecords"; import { + ComposerContextId, CommandId, EnvironmentId, MessageId, @@ -1217,6 +1230,183 @@ describe("deriveAvailableRevertTurnCounts", () => { expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); }); +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: [], + environmentContexts: [], + modelSelectionByProvider: {}, + activeProvider: null, + runtimeMode: null, + interactionMode: null, + }; + + it("detects prompt edits but accepts upload metadata writes", () => { + const store = useComposerDraftStore.getState(); + try { + 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); + 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"); + } finally { + store.clearComposerContent(target); + } + }); + + 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", + }, + ], + environmentContexts: [ + { + version: 1, + kind: "environment", + contextId: ComposerContextId.make("environment-context"), + label: "Before", + environmentId: target.environmentId, + }, + ], + }; + 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" })), + }, + { + ...draft, + environmentContexts: draft.environmentContexts.map((entry) => ({ + ...entry, + label: "After", + })), + }, + ]; + for (const edited of edits) expect(composerDraftUserContentSignature(edited)).not.toBe(before); + }); +}); describe("Open in for the host environment", () => { it("shows both local environments when the primary has a separate WSL home", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d35b6ce28c433..2dad3ef83b7ef 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -66,10 +66,16 @@ import { 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 { environmentThreadDetails } from "../state/threads"; import { @@ -1096,6 +1102,35 @@ 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 ?? [], + environmentContexts: draft?.environmentContexts ?? [], + }); +} + +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 + ); +} + /** Includes the annotation screenshot only within the attachment cap; exceeding it rejects the whole turn. */ export function mergeDirectPreviewAnnotation(input: { readonly images: ReadonlyArray; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index cc68f956aea5f..e7646026535cb 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,6 +1,8 @@ import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; import { armFreshSession, disarmFreshSession, readFreshSessionArm } from "./freshSessionArms"; import { + composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, shouldDockDraftHeroForSubmission, @@ -834,6 +836,13 @@ export function useComposerSend(input: ComposerSendInput) { multipleTargets.push(resolved); } + const draftContentForSend = composerDraftUserContentSignature( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ); + const draftChangedDuringPreparation = () => + composerDraftChangedDuringPreparation(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(); @@ -887,6 +896,15 @@ export function useComposerSend(input: ComposerSendInput) { await dockStarted; } + if (draftChangedDuringPreparation()) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, changedDraftError); + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + return; + } + const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -915,9 +933,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, ); diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index 8ff3bf7fb1392..fb62ffd137f61 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -27,6 +27,8 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import { DEFAULT_INTERACTION_MODE } from "../../types"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { + composerDraftUserContentSignature, + composerDraftChangedDuringPreparation, boundAttachmentIds, type ComposerSendSnapshot, type makeThreadErrorSetter, @@ -114,6 +116,9 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { /** False once a newer send from this composer started. */ readonly isCurrentSend: () => boolean; }) => { + const draftContentForSend = composerDraftUserContentSignature( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ); const { attachments: attachmentsSnapshot, messageText } = sendSnapshot; const failedSelections: ModelSelection[] = []; let clearedDraft = false; @@ -124,6 +129,9 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { // fails every selection; the draft restore below still runs. const sendResult = await settlePromise(async () => { const attachments = await attachmentsPromise; + if (composerDraftChangedDuringPreparation(composerDraftTarget, draftContentForSend)) { + throw new Error("Draft changed while preparing this message. Review it and send again."); + } const fileBlockReason = readFileBlockReason(); if (fileBlockReason !== null) throw new Error(fileBlockReason); const context = buildOutgoingMessageContext(