Quellcode durchsuchen

fix(app): batch multi-file prompt attachments (#18722)

Shoubhit Dash vor 3 Wochen
Ursprung
Commit
9239d877b9

+ 2 - 6
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 = ""
               }}
             />

+ 21 - 17
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,
   }

+ 26 - 0
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 }]
 

+ 1 - 1
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",

+ 44 - 0
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" },
+    ])
+  })
+})

+ 1 - 1
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",