Skip to content

Commit 9239d87

Browse files
authored
fix(app): batch multi-file prompt attachments (#18722)
1 parent fc68c24 commit 9239d87

6 files changed

Lines changed: 95 additions & 25 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
10431043
return true
10441044
}
10451045

1046-
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
1046+
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
10471047
editor: () => editorRef,
10481048
isDialogActive: () => !!dialog.active,
10491049
setDraggingType: (type) => setStore("draggingType", type),
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
13881388
class="hidden"
13891389
onChange={(e) => {
13901390
const list = e.currentTarget.files
1391-
if (list) {
1392-
for (const file of Array.from(list)) {
1393-
void addAttachment(file)
1394-
}
1395-
}
1391+
if (list) void addAttachments(Array.from(list))
13961392
e.currentTarget.value = ""
13971393
}}
13981394
/>

packages/app/src/components/prompt-input/attachments.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
7171

7272
const addAttachment = (file: File) => add(file)
7373

74+
const addAttachments = async (files: File[], toast = true) => {
75+
let found = false
76+
77+
for (const file of files) {
78+
const ok = await add(file, false)
79+
if (ok) found = true
80+
}
81+
82+
if (!found && files.length > 0 && toast) warn()
83+
return found
84+
}
85+
7486
const removeAttachment = (id: string) => {
7587
const current = prompt.current()
7688
const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
8496
event.preventDefault()
8597
event.stopPropagation()
8698

87-
const items = Array.from(clipboardData.items)
88-
const fileItems = items.filter((item) => item.kind === "file")
99+
const files = Array.from(clipboardData.items).flatMap((item) => {
100+
if (item.kind !== "file") return []
101+
const file = item.getAsFile()
102+
return file ? [file] : []
103+
})
89104

90-
if (fileItems.length > 0) {
91-
let found = false
92-
for (const item of fileItems) {
93-
const file = item.getAsFile()
94-
if (!file) continue
95-
const ok = await add(file, false)
96-
if (ok) found = true
97-
}
98-
if (!found) warn()
105+
if (files.length > 0) {
106+
await addAttachments(files)
99107
return
100108
}
101109

@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
169177
const dropped = event.dataTransfer?.files
170178
if (!dropped) return
171179

172-
let found = false
173-
for (const file of Array.from(dropped)) {
174-
const ok = await add(file, false)
175-
if (ok) found = true
176-
}
177-
if (!found && dropped.length > 0) warn()
180+
await addAttachments(Array.from(dropped))
178181
}
179182

180183
onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
191194

192195
return {
193196
addAttachment,
197+
addAttachments,
194198
removeAttachment,
195199
handlePaste,
196200
}

packages/app/src/components/prompt-input/build-request-parts.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
4949
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
5050
})
5151

52+
test("keeps multiple uploaded attachments in order", () => {
53+
const result = buildRequestParts({
54+
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
55+
context: [],
56+
images: [
57+
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
58+
{
59+
type: "image",
60+
id: "img_2",
61+
filename: "b.pdf",
62+
mime: "application/pdf",
63+
dataUrl: "data:application/pdf;base64,BBB",
64+
},
65+
],
66+
text: "check these",
67+
messageID: "msg_multi",
68+
sessionID: "ses_multi",
69+
sessionDirectory: "/repo",
70+
})
71+
72+
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
73+
74+
expect(files).toHaveLength(2)
75+
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
76+
})
77+
5278
test("deduplicates context files when prompt already includes same path", () => {
5379
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
5480

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export const dict = {
276276
"prompt.context.includeActiveFile": "Include active file",
277277
"prompt.context.removeActiveFile": "Remove active file from context",
278278
"prompt.context.removeFile": "Remove file from context",
279-
"prompt.action.attachFile": "Add file",
279+
"prompt.action.attachFile": "Add files",
280280
"prompt.attachment.remove": "Remove attachment",
281281
"prompt.action.send": "Send",
282282
"prompt.action.stop": "Stop",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { Part } from "@opencode-ai/sdk/v2"
3+
import { extractPromptFromParts } from "./prompt"
4+
5+
describe("extractPromptFromParts", () => {
6+
test("restores multiple uploaded attachments", () => {
7+
const parts = [
8+
{
9+
id: "text_1",
10+
type: "text",
11+
text: "check these",
12+
sessionID: "ses_1",
13+
messageID: "msg_1",
14+
},
15+
{
16+
id: "file_1",
17+
type: "file",
18+
mime: "image/png",
19+
url: "data:image/png;base64,AAA",
20+
filename: "a.png",
21+
sessionID: "ses_1",
22+
messageID: "msg_1",
23+
},
24+
{
25+
id: "file_2",
26+
type: "file",
27+
mime: "application/pdf",
28+
url: "data:application/pdf;base64,BBB",
29+
filename: "b.pdf",
30+
sessionID: "ses_1",
31+
messageID: "msg_1",
32+
},
33+
] satisfies Part[]
34+
35+
const result = extractPromptFromParts(parts)
36+
37+
expect(result).toHaveLength(3)
38+
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
39+
expect(result.slice(1)).toMatchObject([
40+
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
41+
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
42+
])
43+
})
44+
})

packages/storybook/.storybook/mocks/app/context/language.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const dict: Record<string, string> = {
88
"prompt.placeholder.shell": "Run a shell command...",
99
"prompt.placeholder.summarizeComment": "Summarize this comment",
1010
"prompt.placeholder.summarizeComments": "Summarize these comments",
11-
"prompt.action.attachFile": "Attach file",
11+
"prompt.action.attachFile": "Attach files",
1212
"prompt.action.send": "Send",
1313
"prompt.action.stop": "Stop",
1414
"prompt.attachment.remove": "Remove attachment",

0 commit comments

Comments
 (0)