From 98f2a873045696215e102ca298e9425418f384c1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:08 +1000 Subject: [PATCH] fix(app): restore queued attachments to the composer when editing (#49081) --- .../app/src/session/composer/queue.test.ts | 54 +++++++++++++++- packages/app/src/session/composer/queue.ts | 61 ++++++++++++++----- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/packages/app/src/session/composer/queue.test.ts b/packages/app/src/session/composer/queue.test.ts index 78108402e94..3bcf3261ea7 100644 --- a/packages/app/src/session/composer/queue.test.ts +++ b/packages/app/src/session/composer/queue.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionInboxInfo } from "@opencode/client/promise" -import { queuedPromptRows } from "./queue" +import { queuedPromptAttachments, queuedPromptRows } from "./queue" const queued = [ { @@ -52,3 +52,55 @@ describe("queuedPromptRows", () => { ]) }) }) + +describe("queuedPromptAttachments", () => { + test("returns inline attachments as composer image parts", () => { + const item = { + ...queued[0], + payload: { + text: "", + files: [ + { data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" }, + { data: "aGk=", mime: "application/pdf", source: { type: "inline" as const } }, + ], + }, + } satisfies SessionInboxInfo + + expect(queuedPromptAttachments(item)).toEqual([ + { + type: "image", + id: "msg_original:file:0", + filename: "shot.png", + mime: "image/png", + blob: { id: "data:image/png;base64,aGk=", url: "data:image/png;base64,aGk=" }, + }, + { + type: "image", + id: "msg_original:file:1", + filename: "attachment", + mime: "application/pdf", + blob: { id: "data:application/pdf;base64,aGk=", url: "data:application/pdf;base64,aGk=" }, + }, + ]) + }) + + test("leaves file mentions and context files in the payload", () => { + const item = { + ...queued[0], + payload: { + text: "see @src/a.ts", + files: [ + { + data: "aGk=", + mime: "text/plain", + source: { type: "inline" as const }, + mention: { start: 4, end: 13, text: "@src/a.ts" }, + }, + { data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///src/b.ts" } }, + ], + }, + } satisfies SessionInboxInfo + + expect(queuedPromptAttachments(item)).toEqual([]) + }) +}) diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts index 540779cc943..6d8310a86d2 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -8,7 +8,7 @@ import type { ComposerStateTarget } from "@/composer/submission-state" import type { ImageAttachmentPart, Prompt } from "@/composer/state" import { clonePrompt, promptLength } from "@/composer/prompt-parts" import { buildPromptRequest } from "@/composer/request" -import { blobDataUrl } from "@/runtime/persistence/drafts" +import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts" import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { useWorkspaceLocation } from "@/workspaces/location" @@ -159,7 +159,10 @@ export function createSessionQueue(input: { }) const text = queuedPromptText(item) input.draft.mode.set("normal") - input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + input.draft.set( + [{ type: "text", content: text, start: 0, end: text.length }, ...queuedPromptAttachments(item)], + text.length, + ) input.restoreFocus(text.length) return true } @@ -179,9 +182,15 @@ export function createSessionQueue(input: { if (!editing || mutation.isPending) return const prompt = clonePrompt(input.draft.current()) const text = prompt.map((part) => ("content" in part ? part.content : "")).join("") - if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit() + const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image") + if (!text.trim() && !images.length) return cancelEdit() const item = queued().find((entry) => entry.id === editing.id) - const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image") + const original = item ? queuedPromptAttachments(item) : [] + const pristine = + item && + text.trim() === queuedPromptText(item) && + images.length === original.length && + images.every((image, index) => image.id === original[index].id) if (pristine && delivery === "queue") return cancelEdit() mutation.mutate({ type: "edit", @@ -246,12 +255,32 @@ export function queuedPromptText(item: QueuedPrompt) { return typeof display === "string" && display.length > 0 ? display : item.payload.text } +// Inline attachments are the files the composer added itself, so they return +// to it as image parts that an edit can remove or extend. Mentions and +// `file://` context stay in the payload; see editedPromptInput. +export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] { + return (item.payload.files ?? []) + .filter((file) => isComposerAttachment(file)) + .map((file, index) => ({ + type: "image", + id: `${item.id}:file:${index}`, + filename: file.name ?? "attachment", + mime: file.mime, + blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`), + })) +} + +function isComposerAttachment(file: NonNullable[number]) { + return !file.mention && file.source.type === "inline" +} + // Confirming an edit submits the current composer content as the replacement: -// mentions and images added during the edit are parsed like a normal -// submission, the original's stored attachments are preserved, and the -// review-comment notes appended to the original's model-visible text survive. -// Ambient composer context (open review comments) stays out: it belongs to -// the next fresh prompt, not to a queued edit. +// mentions and attachments are parsed like a normal submission, so removed +// attachments drop and added ones join. Stored file mentions and context files +// the composer cannot show are preserved, and the review-comment notes appended +// to the original's model-visible text survive. Ambient composer context (open +// review comments) stays out: it belongs to the next fresh prompt, not to a +// queued edit. async function editedPromptInput( sessionID: string, directory: string, @@ -297,12 +326,14 @@ async function editedPromptInput( sessionID, text: request.text + notes, files: [ - ...(payload?.files?.map((file) => ({ - uri: `data:${file.mime};base64,${file.data}`, - name: file.name, - description: file.description, - mention: mention(file.mention), - })) ?? []), + ...(payload?.files + ?.filter((file) => !isComposerAttachment(file)) + .map((file) => ({ + uri: `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: mention(file.mention), + })) ?? []), ...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), ], agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),