summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-03-23 12:14:17 +0530
committerGitHub <[email protected]>2026-03-23 06:44:17 +0000
commit9239d877b9602a5a80e9e69e744abfe011f5f991 (patch)
tree7c097bec13af5e693a7f52a74aebcb7136de782a /packages
parentfc68c244333a3829177fd0594aa3d5c018203487 (diff)
downloadopencode-9239d877b9602a5a80e9e69e744abfe011f5f991.tar.gz
opencode-9239d877b9602a5a80e9e69e744abfe011f5f991.zip
fix(app): batch multi-file prompt attachments (#18722)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/prompt-input.tsx8
-rw-r--r--packages/app/src/components/prompt-input/attachments.ts38
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts26
-rw-r--r--packages/app/src/i18n/en.ts2
-rw-r--r--packages/app/src/utils/prompt.test.ts44
-rw-r--r--packages/storybook/.storybook/mocks/app/context/language.ts2
6 files changed, 95 insertions, 25 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index f3d3e135d..34f83b13e 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
- const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
+ const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
- if (list) {
- for (const file of Array.from(list)) {
- void addAttachment(file)
- }
- }
+ if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
index eca508c6c..fa9930f68 100644
--- a/packages/app/src/components/prompt-input/attachments.ts
+++ b/packages/app/src/components/prompt-input/attachments.ts
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file)
+ const addAttachments = async (files: File[], toast = true) => {
+ let found = false
+
+ for (const file of files) {
+ const ok = await add(file, false)
+ if (ok) found = true
+ }
+
+ if (!found && files.length > 0 && toast) warn()
+ return found
+ }
+
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault()
event.stopPropagation()
- const items = Array.from(clipboardData.items)
- const fileItems = items.filter((item) => item.kind === "file")
+ const files = Array.from(clipboardData.items).flatMap((item) => {
+ if (item.kind !== "file") return []
+ const file = item.getAsFile()
+ return file ? [file] : []
+ })
- if (fileItems.length > 0) {
- let found = false
- for (const item of fileItems) {
- const file = item.getAsFile()
- if (!file) continue
- const ok = await add(file, false)
- if (ok) found = true
- }
- if (!found) warn()
+ if (files.length > 0) {
+ await addAttachments(files)
return
}
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
- let found = false
- for (const file of Array.from(dropped)) {
- const ok = await add(file, false)
- if (ok) found = true
- }
- if (!found && dropped.length > 0) warn()
+ await addAttachments(Array.from(dropped))
}
onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return {
addAttachment,
+ addAttachments,
removeAttachment,
handlePaste,
}
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index 4c2e2d8be..ce09ae921 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
+ test("keeps multiple uploaded attachments in order", () => {
+ const result = buildRequestParts({
+ prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
+ context: [],
+ images: [
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ {
+ type: "image",
+ id: "img_2",
+ filename: "b.pdf",
+ mime: "application/pdf",
+ dataUrl: "data:application/pdf;base64,BBB",
+ },
+ ],
+ text: "check these",
+ messageID: "msg_multi",
+ sessionID: "ses_multi",
+ sessionDirectory: "/repo",
+ })
+
+ const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
+
+ expect(files).toHaveLength(2)
+ expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
+ })
+
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 8efd9d3bc..579b740d3 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -276,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
- "prompt.action.attachFile": "Add file",
+ "prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts
new file mode 100644
index 000000000..1ecaf02c9
--- /dev/null
+++ b/packages/app/src/utils/prompt.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import type { Part } from "@opencode-ai/sdk/v2"
+import { extractPromptFromParts } from "./prompt"
+
+describe("extractPromptFromParts", () => {
+ test("restores multiple uploaded attachments", () => {
+ const parts = [
+ {
+ id: "text_1",
+ type: "text",
+ text: "check these",
+ sessionID: "ses_1",
+ messageID: "msg_1",
+ },
+ {
+ id: "file_1",
+ type: "file",
+ mime: "image/png",
+ url: "data:image/png;base64,AAA",
+ filename: "a.png",
+ sessionID: "ses_1",
+ messageID: "msg_1",
+ },
+ {
+ id: "file_2",
+ type: "file",
+ mime: "application/pdf",
+ url: "data:application/pdf;base64,BBB",
+ filename: "b.pdf",
+ sessionID: "ses_1",
+ messageID: "msg_1",
+ },
+ ] satisfies Part[]
+
+ const result = extractPromptFromParts(parts)
+
+ expect(result).toHaveLength(3)
+ expect(result[0]).toMatchObject({ type: "text", content: "check these" })
+ expect(result.slice(1)).toMatchObject([
+ { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
+ ])
+ })
+})
diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts
index 874465542..f75240b9c 100644
--- a/packages/storybook/.storybook/mocks/app/context/language.ts
+++ b/packages/storybook/.storybook/mocks/app/context/language.ts
@@ -8,7 +8,7 @@ const dict: Record<string, string> = {
"prompt.placeholder.shell": "Run a shell command...",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
- "prompt.action.attachFile": "Attach file",
+ "prompt.action.attachFile": "Attach files",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.attachment.remove": "Remove attachment",