fix(app): restore queued attachments to the composer when editing (#49081)

This commit is contained in:
Luke Parker
2026-09-15 10:52:08 +10:00
committed by GitHub
Unverified
parent 23402629c5
commit 98f2a87304
2 changed files with 99 additions and 16 deletions
@@ -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([])
})
})
+46 -15
View File
@@ -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<QueuedPrompt["payload"]["files"]>[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) })),