From c071361311766c3d3f884902c44ccc29a8ed6888 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:45:52 +1100 Subject: [PATCH 1/3] fix(web): cancelled queued-message edits release their staged uploads Co-Authored-By: Claude Opus 5.5 --- .../components/chat/queuedMessageEdit.test.ts | 7 +++++++ .../web/src/components/chat/queuedMessageEdit.ts | 16 +++++++++++++++- apps/web/src/components/chat/useQueuedRunEdit.ts | 16 +++++++++++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index fd5cc6ac17062..b0f05fd8ddc2c 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -7,6 +7,7 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, } from "../../composerDraftStore"; +import { readAttachmentUpload, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit"; const environmentId = EnvironmentId.make("remote-environment"); @@ -90,10 +91,16 @@ describe("queued message file edits", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadTarget, "Separate draft"); store.addFiles(editTarget, [file]); + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [file.id]: { status: "ready", environmentId, attachmentId: uploadedFile.id }, + }, + }); expect(recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "" })).toBe( "discarded", ); expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft"); expect(store.getComposerDraft(editTarget)).toBeNull(); + expect(readAttachmentUpload(file.id)).toBeUndefined(); }); }); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 5d1d9fce27103..d70313fb5c930 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -7,6 +7,20 @@ import { type ComposerImageAttachment, type ComposerThreadTarget, } from "../../composerDraftStore"; +import { releaseDraftAttachments } from "../../lib/attachmentUploadQueue"; + +/** Staged attachments upload as soon as they are added, so a dropped edit must release them. */ +export function discardQueuedEditDraft(target: ComposerThreadTarget): void { + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(target); + if (draft) { + releaseDraftAttachments([...draft.images, ...draft.files]); + for (const image of draft.images) { + if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl); + } + } + store.clearComposerContent(target); +} export function recoverQueuedMessageEdit(input: { readonly editTarget: ComposerThreadTarget; @@ -24,7 +38,7 @@ export function recoverQueuedMessageEdit(input: { if (recovery === "kept") { store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); } else { - store.clearComposerContent(input.editTarget); + discardQueuedEditDraft(input.editTarget); } return recovery; } diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index d9185775ea000..81750d953652d 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -54,7 +54,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { stackedThreadToast, toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; import { fileAttachmentCapabilityBlockReason } from "./composerAttachmentFiles"; -import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit"; +import { + discardQueuedEditDraft, + prepareQueuedEditAttachments, + recoverQueuedMessageEdit, +} from "./queuedMessageEdit"; import type { EditQueuedRunRequest } from "./QueuedRunsControl"; interface EditingQueuedRun { @@ -221,10 +225,12 @@ export function useQueuedRunEdit(input: { const focusComposerSoon = () => { window.requestAnimationFrame(() => composerRef.current?.focusAtEnd()); }; - const clearEditDraft = (runId: RunId) => - useComposerDraftStore - .getState() - .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); + const clearEditDraft = (runId: RunId) => { + const target = queuedEditDraftTarget(routeThreadRef, runId); + // A save in flight still sends these uploads. + if (isSaving) useComposerDraftStore.getState().clearComposerContent(target); + else discardQueuedEditDraft(target); + }; const beginEdit = useStableCallback((request: EditQueuedRunRequest) => { if (activeThreadId === null) return; From 4cdf8a7e5247b9c930fc76475f124476bee573f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:30:29 +1100 Subject: [PATCH 2/3] fix(web): release cancelled queued-edit resources after saves settle --- .../chat/useQueuedRunEdit.browser.test.tsx | 78 +++++++++++++++++++ .../src/components/chat/useQueuedRunEdit.ts | 23 +++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 3118cd3ec7100..4b8c35884d578 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -28,6 +28,7 @@ import { deriveComposerSendState } from "../ChatView.logic"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; import { useQueuedRunEdit } from "./useQueuedRunEdit"; +import { readAttachmentUpload, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; interface RunStatus { readonly id: RunId; @@ -150,6 +151,7 @@ function view( function setup() { mocks.editQueuedRun.mockReset(); + useAttachmentUploadStore.setState({ uploadsByImageId: {} }); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); setRuns(THREAD_A, "queued"); setRuns(THREAD_B, "queued"); @@ -406,6 +408,82 @@ describe("useQueuedRunEdit", () => { expect(promptOf(THREAD_A)).toBe(""); }); + it.each([ + { transition: "cancel", result: "Success" }, + { transition: "cancel", result: "Failure" }, + { transition: "keep", result: "Failure" }, + { transition: "recover", result: "Failure" }, + { transition: "replace", result: "Success" }, + { transition: "replace", result: "Failure" }, + ] as const)( + "settles saved attachments after $transition and $result", + async ({ transition, result }) => { + const { hook } = setup(); + let finishSave!: (result: { readonly _tag: "Success" | "Failure" }) => void; + let started!: () => void; + const saving = new Promise<{ readonly _tag: "Success" | "Failure" }>((resolve) => { + finishSave = resolve; + }); + const requested = new Promise((resolve) => { + started = resolve; + }); + mocks.editQueuedRun.mockImplementation(() => { + started(); + return saving; + }); + await using _screen = await render(view(THREAD_A, "queued", hook)); + await beginChangedEdit(hook); + const image = { + type: "image" as const, + id: "saved-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 5, + file: new File(["image"], "image.png", { type: "image/png" }), + previewUrl: "blob:saved-image", + }; + using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + await act(() => + useComposerDraftStore.getState().addImage(requireHook(hook).draftTarget, image), + ); + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [image.id]: { status: "ready", environmentId: ENV, attachmentId: "saved-upload" }, + }, + }); + const target = requireHook(hook).draftTarget; + const saved = requireHook(hook).saveEdit( + { ...savedContent("changed"), images: [image] }, + () => {}, + ); + await act(() => requested); + expect(requireHook(hook).isSaving).toBe(true); + if (transition === "cancel") await act(() => requireHook(hook).cancelEdit()); + if (transition === "recover") await act(() => setRuns(THREAD_A, "running")); + if (transition === "replace") { + await beginChangedEdit(hook); + await act(() => + useComposerDraftStore.getState().addImage(requireHook(hook).draftTarget, image), + ); + } + expect(readAttachmentUpload(image.id)).toBeDefined(); + expect(revoke).not.toHaveBeenCalled(); + finishSave({ _tag: result }); + await act(() => saved); + if (transition === "cancel") { + expect(readAttachmentUpload(image.id)).toBeUndefined(); + expect(revoke).toHaveBeenCalledWith(image.previewUrl); + expect(useComposerDraftStore.getState().getComposerDraft(target)).toBeNull(); + } else { + expect(readAttachmentUpload(image.id)).toBeDefined(); + expect(revoke).not.toHaveBeenCalled(); + const owner = transition === "recover" ? THREAD_A : target; + expect(useComposerDraftStore.getState().getComposerDraft(owner)?.images).toEqual([image]); + } + useAttachmentUploadStore.setState({ uploadsByImageId: {} }); + }, + ); + it("leaves a newer edit alone when a save from before navigation finishes", async () => { const { hook } = setup(); let finishSave: (result: { readonly _tag: "Success" }) => void = () => {}; diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 8f0d4a95bc263..e7f321bec9dc2 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -372,10 +372,29 @@ export function useQueuedRunEdit(input: { }); setThreadError(editing.threadId, error); const current = finishSave(error === null); + if (!current) { + // A failed save can recover into the thread draft; a newer edit can also retain resources. + const drafts = Object.values(useComposerDraftStore.getState().draftsByThreadKey); + const retainedIds = new Set( + drafts.flatMap((draft) => + [...draft.images, ...draft.files].map((attachment) => attachment.id), + ), + ); + releaseDraftAttachments( + newAttachments.filter((attachment) => !retainedIds.has(attachment.id)), + ); + const retainedPreviews = new Set( + drafts.flatMap((draft) => draft.images.map((image) => image.previewUrl)), + ); + for (const image of content.images) { + if (image.previewUrl.startsWith("blob:") && !retainedPreviews.has(image.previewUrl)) { + URL.revokeObjectURL(image.previewUrl); + } + } + return; + } if (error !== null) return; releaseDraftAttachments(newAttachments); - // The edit was cancelled or begun again while this save ran; its drafts are the newer edit's. - if (!current) return; promptRef.current = ""; clearEditDraft(editing.runId); composerRef.current?.resetCursorState(); From 72465fccecc5abdb56f04b5311120ed0d83cde1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 14:21:31 +0000 Subject: [PATCH 3/3] test(web): keep only queued-edit settle cases that reach the new cleanup Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/chat/useQueuedRunEdit.browser.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 4b8c35884d578..28785ecd768af 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -410,11 +410,8 @@ describe("useQueuedRunEdit", () => { it.each([ { transition: "cancel", result: "Success" }, - { transition: "cancel", result: "Failure" }, - { transition: "keep", result: "Failure" }, { transition: "recover", result: "Failure" }, { transition: "replace", result: "Success" }, - { transition: "replace", result: "Failure" }, ] as const)( "settles saved attachments after $transition and $result", async ({ transition, result }) => { @@ -480,7 +477,6 @@ describe("useQueuedRunEdit", () => { const owner = transition === "recover" ? THREAD_A : target; expect(useComposerDraftStore.getState().getComposerDraft(owner)?.images).toEqual([image]); } - useAttachmentUploadStore.setState({ uploadsByImageId: {} }); }, );