From 50bcba009fb83e7f0efebdaf179ab118b21d17d4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 00:32:23 -0500 Subject: [PATCH 1/6] test(web): reproduce clipboard attachments returning after deletion Co-Authored-By: GPT-6.1 Sol --- .../useComposerContextImport.browser.test.tsx | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 apps/web/src/components/chat/useComposerContextImport.browser.test.tsx diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx new file mode 100644 index 0000000000000..0cae0cf2879cc --- /dev/null +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -0,0 +1,163 @@ +import { useEffect } from "react"; +import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { rememberCopiedComposerAttachments } from "../../lib/copiedComposerAttachments"; +import { pendingDraftWork } from "./pendingDraftWork"; +import { useComposerContextImport } from "./useComposerContextImport"; + +vi.mock("~/state/use-atom-query-runner", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomQueryRunner: () => async () => ({ _tag: "Success", value: { relativeUrl: "/file" } }), +})); +vi.mock("../../state/use-atom-command", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomCommand: () => () => {}, +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }), +})); + +type ImportInput = Parameters[0]; +type Importer = ReturnType; + +function Harness({ input, ready }: { input: ImportInput; ready: (value: Importer) => void }) { + const importer = useComposerContextImport(input); + useEffect(() => ready(importer)); + return null; +} + +it.each([ + { kind: "file", edit: "delete" }, + { kind: "image", edit: "delete" }, + { kind: "file", edit: "undo" }, + { kind: "file", edit: "retain" }, + { kind: "file", edit: "switch" }, + { kind: "file", edit: "cached" }, + { kind: "image", edit: "annotation" }, + { kind: "image", edit: "delete-annotation" }, +] as const)("settles a delayed $kind import after $edit", async ({ kind, edit }) => { + const target = DraftId.make(`clipboard-${kind}-${edit}`); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const sourceEnvironmentId = EnvironmentId.make("clipboard-source"); + rememberCopiedComposerAttachments( + sourceEnvironmentId, + edit === "cached" + ? new Map([["source-attachment", new File(["notes"], "notes.txt")]]) + : new Map(), + ); + using _copy = { + [Symbol.dispose]: () => rememberCopiedComposerAttachments(sourceEnvironmentId, new Map()), + }; + const promptRef = { current: "" }; + const downloaded = Promise.withResolvers(); + const requested = Promise.withResolvers(); + const settled = Promise.withResolvers(); + const end = pendingDraftWork.end.bind(pendingDraftWork); + using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { + end(key); + settled.resolve(); + }); + using _fetch = stubGlobal("fetch", () => { + requested.resolve(); + return downloaded.promise; + }); + const input = { + environmentId: EnvironmentId.make("clipboard-target"), + activeThread: null, + activeThreadId: null, + composerDraftTarget: target, + attachmentDraftTarget: target, + attachmentTargetKey: target, + attachmentTargetKeyRef: { current: target }, + questionAttachmentTarget: null, + pendingUserInputs: [], + composerContextRecords: new Map(), + composerImages: [], + composerFilesRef: { current: [] }, + pendingImageCompressionsRef: { current: new Map() }, + pendingThreadImportsRef: { current: new Map() }, + promptRef, + addComposerFilesToDraft: (files: Parameters[1]) => + store.addFiles(target, files, { appendReference: true }), + } satisfies ImportInput & { promptRef: { current: string } }; + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + const contextId = ComposerContextId.make(`${kind}_source`); + const attachment = { + version: 1, + kind, + contextId, + label: "notes.txt", + attachmentId: "source-attachment", + name: "notes.txt", + mimeType: kind === "file" ? "text/plain" : "image/png", + sizeBytes: 5, + } satisfies ComposerContextRecord; + const annotation = { + version: 1, + kind: "preview-annotation", + contextId: ComposerContextId.make("preview-annotation_source"), + label: "Page annotation", + annotationId: "source", + pageUrl: "http://localhost/page", + pageTitle: "Page", + comment: "Check this", + targetSummary: "Page", + styleChanges: [], + screenshotContextId: contextId, + } satisfies ComposerContextRecord; + const annotated = edit === "annotation" || edit === "delete-annotation"; + let pastedPrompt = ""; + await act(async () => { + const remapped = importer.importContextRecords( + annotated ? [attachment, annotation] : [attachment], + sourceEnvironmentId, + ); + pastedPrompt = formatInlineContextReference({ + kind: annotated ? "preview-annotation" : kind, + contextId: remapped.get(annotated ? annotation.contextId : contextId)!, + label: annotated ? "Page annotation" : "notes.txt", + }); + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + if (edit !== "cached") await requested.promise; + }); + if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); + await act(() => { + if (edit !== "annotation" && edit !== "retain" && edit !== "cached") { + promptRef.current = "Keep this edit"; + store.setPrompt(target, promptRef.current); + } + if (edit === "undo") { + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + } + if (edit === "switch") input.attachmentTargetKeyRef.current = DraftId.make("other-draft"); + }); + await act(async () => { + downloaded.resolve(new Response("notes", { headers: { "Content-Type": attachment.mimeType } })); + await settled.promise; + }); + expect(pendingDraftWork.has(target)).toBe(false); + const draft = store.getComposerDraft(target)!; + expect(draft.prompt).toBe(promptRef.current); + const attachments = kind === "file" ? draft.files : draft.images; + expect(attachments.map((file) => file.name)).toEqual( + edit === "undo" || edit === "retain" || edit === "annotation" || edit === "cached" + ? ["notes.txt"] + : [], + ); +}); From 4ac59508a9ee22fabce692e20e2af0430a3e0937 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 00:34:18 -0500 Subject: [PATCH 2/6] fix(web): keep deleted clipboard attachments out of the draft Read the live reference after downloading so deleting a chip cannot stage its bytes or recreate its reference. Preserve referenced annotation screenshots and undo before completion. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../useComposerContextImport.browser.test.tsx | 28 +++++++++++++------ .../chat/useComposerContextImport.ts | 17 +++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b74e691ddd139..eeac1a43bee39 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1434,6 +1434,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingImageCompressionsRef, pendingThreadImportsRef, addComposerFilesToDraft, diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index 0cae0cf2879cc..ee363d9da5590 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -31,6 +31,14 @@ function Harness({ input, ready }: { input: ImportInput; ready: (value: Importer return null; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + it.each([ { kind: "file", edit: "delete" }, { kind: "image", edit: "delete" }, @@ -55,9 +63,9 @@ it.each([ [Symbol.dispose]: () => rememberCopiedComposerAttachments(sourceEnvironmentId, new Map()), }; const promptRef = { current: "" }; - const downloaded = Promise.withResolvers(); - const requested = Promise.withResolvers(); - const settled = Promise.withResolvers(); + const downloaded = deferred(); + const requested = deferred(); + const settled = deferred(); const end = pendingDraftWork.end.bind(pendingDraftWork); using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { end(key); @@ -69,7 +77,7 @@ it.each([ }); const input = { environmentId: EnvironmentId.make("clipboard-target"), - activeThread: null, + activeThread: undefined, activeThreadId: null, composerDraftTarget: target, attachmentDraftTarget: target, @@ -96,16 +104,20 @@ it.each([ />, ); const contextId = ComposerContextId.make(`${kind}_source`); - const attachment = { - version: 1, - kind, + const attachmentFields = { + version: 1 as const, contextId, label: "notes.txt", attachmentId: "source-attachment", name: "notes.txt", mimeType: kind === "file" ? "text/plain" : "image/png", sizeBytes: 5, - } satisfies ComposerContextRecord; + }; + const attachment = ( + kind === "file" + ? { ...attachmentFields, kind: "file" as const } + : { ...attachmentFields, kind: "image" as const } + ) satisfies ComposerContextRecord; const annotation = { version: 1, kind: "preview-annotation", diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 9fedbee2f579e..9655939bac01a 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -32,6 +32,7 @@ import { import { assetEnvironment } from "~/state/assets"; import { copiedComposerAttachmentFile } from "~/lib/copiedComposerAttachments"; import { countQuestionAttachments, questionAttachmentDraftId } from "../../questionAttachments"; +import { collectInlineContextIds } from "../../lib/composerContextReferences"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { orchestrationEnvironment } from "~/state/orchestration"; import { pendingDraftWork } from "./pendingDraftWork"; @@ -74,6 +75,7 @@ export function useComposerContextImport(input: { readonly composerContextRecords: ComposerDraftContextRecords; readonly composerImages: ReadonlyArray; readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; + readonly promptRef: RefObject; readonly pendingImageCompressionsRef: RefObject>; readonly pendingThreadImportsRef: RefObject>; readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[]; @@ -91,6 +93,7 @@ export function useComposerContextImport(input: { composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingImageCompressionsRef, pendingThreadImportsRef, addComposerFilesToDraft, @@ -171,6 +174,20 @@ export function useComposerContextImport(input: { fail("Downloading it from the source failed."); return; } + // A deleted chip must not return with its download. Undo can restore it before + // completion, and an annotation can still own its screenshot without an image chip. + const references = new Set(collectInlineContextIds(promptRef.current)); + if ( + !references.has(toKindScopedComposerContextId(record.kind, localId)) && + !( + record.kind === "image" && + references.has(previewAnnotationContextId(localId)) && + getComposerDraft(composerDraftTarget)?.previewAnnotations.some( + (annotation) => annotation.id === localId, + ) + ) + ) + return; file = new File([blob], record.name, { type: record.mimeType || blob.type }); } // The draft these bytes belong to may have been sent or switched away from while they From b2aabe04ad8eff7d708d6eb7ea2b0357877ff1ae Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 20:45:11 -0500 Subject: [PATCH 3/6] test(web): install context import spies after render The pendingDraftWork.end spy and fetch stub were acquired before the render, so they disposed after the unmount. The first render reads neither, so acquire them after mounting per the browser test rule. Co-Authored-By: Claude Opus 5.5 --- .../useComposerContextImport.browser.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index 8a7a20b60d491..644e70ba11467 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -66,15 +66,6 @@ it.each([ const downloaded = deferred(); const requested = deferred(); const settled = deferred(); - const end = pendingDraftWork.end.bind(pendingDraftWork); - using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { - end(key); - settled.resolve(); - }); - using _fetch = stubGlobal("fetch", () => { - requested.resolve(); - return downloaded.promise; - }); const input = { environmentId: EnvironmentId.make("clipboard-target"), activeThread: undefined, @@ -102,6 +93,15 @@ it.each([ }} />, ); + const end = pendingDraftWork.end.bind(pendingDraftWork); + using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { + end(key); + settled.resolve(); + }); + using _fetch = stubGlobal("fetch", () => { + requested.resolve(); + return downloaded.promise; + }); const contextId = ComposerContextId.make(`${kind}_source`); const attachmentFields = { version: 1 as const, From 88d1fa65dd781e9acc809a02f9f30421e02fc328 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:05:55 +0000 Subject: [PATCH 4/6] fix(web): preserve downloaded question attachments --- .../useComposerContextImport.browser.test.tsx | 15 +++++++++++---- .../components/chat/useComposerContextImport.ts | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index 644e70ba11467..bf79499db6c1c 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -46,6 +46,8 @@ it.each([ { kind: "file", edit: "retain" }, { kind: "file", edit: "switch" }, { kind: "file", edit: "cached" }, + { kind: "file", edit: "question" }, + { kind: "image", edit: "question" }, { kind: "image", edit: "annotation" }, { kind: "image", edit: "delete-annotation" }, ] as const)("settles a delayed $kind import after $edit", async ({ kind, edit }) => { @@ -74,7 +76,7 @@ it.each([ attachmentDraftTarget: target, attachmentTargetKey: target, attachmentTargetKeyRef: { current: target }, - questionAttachmentTarget: null, + questionAttachmentTarget: edit === "question" ? target : null, pendingUserInputs: [], composerContextRecords: new Map(), composerImages: [], @@ -82,7 +84,7 @@ it.each([ pendingThreadImportsRef: { current: new Map() }, promptRef, addComposerFilesToDraft: (files: Parameters[1]) => - store.addFiles(target, files, { appendReference: true }), + store.addFiles(target, files, { appendReference: edit !== "question" }), } satisfies ImportInput & { promptRef: { current: string } }; let importer!: Importer; await using _screen = await render( @@ -142,13 +144,14 @@ it.each([ contextId: remapped.get(annotated ? annotation.contextId : contextId)!, label: annotated ? "Page annotation" : "notes.txt", }); + if (edit === "question") pastedPrompt = "Question answer"; promptRef.current = pastedPrompt; store.setPrompt(target, pastedPrompt); if (edit !== "cached") await requested.promise; }); if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); await act(() => { - if (edit !== "annotation" && edit !== "retain" && edit !== "cached") { + if (edit !== "annotation" && edit !== "retain" && edit !== "cached" && edit !== "question") { promptRef.current = "Keep this edit"; store.setPrompt(target, promptRef.current); } @@ -167,7 +170,11 @@ it.each([ expect(draft.prompt).toBe(promptRef.current); const attachments = kind === "file" ? draft.files : draft.images; expect(attachments.map((file) => file.name)).toEqual( - edit === "undo" || edit === "retain" || edit === "annotation" || edit === "cached" + edit === "undo" || + edit === "retain" || + edit === "annotation" || + edit === "cached" || + edit === "question" ? ["notes.txt"] : [], ); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index a85e925b3b9a9..570cf8c8275d6 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -173,10 +173,10 @@ export function useComposerContextImport(input: { fail("Downloading it from the source failed."); return; } - // A deleted chip must not return with its download. Undo can restore it before - // completion, and an annotation can still own its screenshot without an image chip. + // Question attachments have no inline reference; annotations can own their screenshots. const references = new Set(collectInlineContextIds(promptRef.current)); if ( + !questionAttachmentTarget && !references.has(toKindScopedComposerContextId(record.kind, localId)) && !( record.kind === "image" && From 0d86eb2413d4b7455cc1d2ad41035b1cb5137aa8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:09:57 +0000 Subject: [PATCH 5/6] test(web): isolate clipboard import destination guard --- .../chat/useComposerContextImport.browser.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index bf79499db6c1c..47a2f97a15952 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -151,7 +151,13 @@ it.each([ }); if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); await act(() => { - if (edit !== "annotation" && edit !== "retain" && edit !== "cached" && edit !== "question") { + if ( + edit !== "annotation" && + edit !== "retain" && + edit !== "cached" && + edit !== "question" && + edit !== "switch" + ) { promptRef.current = "Keep this edit"; store.setPrompt(target, promptRef.current); } From cb12522594ae06270ff2b0203fc809b401f3b68b Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:06:48 +0000 Subject: [PATCH 6/6] test(web): provide linked environments to context import harness --- .../components/chat/useComposerContextImport.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index 47a2f97a15952..a050b0f51edf0 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -70,6 +70,7 @@ it.each([ const settled = deferred(); const input = { environmentId: EnvironmentId.make("clipboard-target"), + linkedEnvironments: new Map(), activeThread: undefined, activeThreadId: null, composerDraftTarget: target,