mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): restore queued attachments to the composer when editing (#49081)
This commit is contained in:
committed by
GitHub
Unverified
parent
23402629c5
commit
98f2a87304
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
Reference in New Issue
Block a user