Procházet zdrojové kódy

fix(app): support text attachments (#17335)

Adam před 6 měsíci
rodič
revize
843f188aaa

+ 5 - 4
packages/app/src/components/prompt-input.tsx

@@ -38,7 +38,8 @@ import { usePlatform } from "@/context/platform"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
-import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
+import { createPromptAttachments } from "./prompt-input/attachments"
+import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
 import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
@@ -1007,7 +1008,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return true
   }
 
-  const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
+  const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
     editor: () => editorRef,
     isFocused,
     isDialogActive: () => !!dialog.active,
@@ -1247,7 +1248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           onOpen={(attachment) =>
             dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
           }
-          onRemove={removeImageAttachment}
+          onRemove={removeAttachment}
           removeLabel={language.t("prompt.attachment.remove")}
         />
         <div
@@ -1311,7 +1312,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="hidden"
               onChange={(e) => {
                 const file = e.currentTarget.files?.[0]
-                if (file) addImageAttachment(file)
+                if (file) void addAttachment(file)
                 e.currentTarget.value = ""
               }}
             />

+ 24 - 0
packages/app/src/components/prompt-input/attachments.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test"
+import { attachmentMime } from "./files"
+
+describe("attachmentMime", () => {
+  test("keeps PDFs when the browser reports the mime", async () => {
+    const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
+    expect(await attachmentMime(file)).toBe("application/pdf")
+  })
+
+  test("normalizes structured text types to text/plain", async () => {
+    const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
+    expect(await attachmentMime(file)).toBe("text/plain")
+  })
+
+  test("accepts text files even with a misleading browser mime", async () => {
+    const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
+    expect(await attachmentMime(file)).toBe("text/plain")
+  })
+
+  test("rejects binary files", async () => {
+    const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
+    expect(await attachmentMime(file)).toBeUndefined()
+  })
+})

+ 63 - 39
packages/app/src/components/prompt-input/attachments.ts

@@ -4,12 +4,27 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
 import { useLanguage } from "@/context/language"
 import { uuid } from "@/utils/uuid"
 import { getCursorPosition } from "./editor-dom"
-
-export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
-export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+import { attachmentMime } from "./files"
 const LARGE_PASTE_CHARS = 8000
 const LARGE_PASTE_BREAKS = 120
 
+function dataUrl(file: File, mime: string) {
+  return new Promise<string>((resolve) => {
+    const reader = new FileReader()
+    reader.addEventListener("error", () => resolve(""))
+    reader.addEventListener("load", () => {
+      const value = typeof reader.result === "string" ? reader.result : ""
+      const idx = value.indexOf(",")
+      if (idx === -1) {
+        resolve(value)
+        return
+      }
+      resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
+    })
+    reader.readAsDataURL(file)
+  })
+}
+
 function largePaste(text: string) {
   if (text.length >= LARGE_PASTE_CHARS) return true
   let breaks = 0
@@ -35,28 +50,41 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   const prompt = usePrompt()
   const language = useLanguage()
 
-  const addImageAttachment = async (file: File) => {
-    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+  const warn = () => {
+    showToast({
+      title: language.t("prompt.toast.pasteUnsupported.title"),
+      description: language.t("prompt.toast.pasteUnsupported.description"),
+    })
+  }
 
-    const reader = new FileReader()
-    reader.onload = () => {
-      const editor = input.editor()
-      if (!editor) return
-      const dataUrl = reader.result as string
-      const attachment: ImageAttachmentPart = {
-        type: "image",
-        id: uuid(),
-        filename: file.name,
-        mime: file.type,
-        dataUrl,
-      }
-      const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
-      prompt.set([...prompt.current(), attachment], cursorPosition)
+  const add = async (file: File, toast = true) => {
+    const mime = await attachmentMime(file)
+    if (!mime) {
+      if (toast) warn()
+      return false
     }
-    reader.readAsDataURL(file)
+
+    const editor = input.editor()
+    if (!editor) return false
+
+    const url = await dataUrl(file, mime)
+    if (!url) return false
+
+    const attachment: ImageAttachmentPart = {
+      type: "image",
+      id: uuid(),
+      filename: file.name,
+      mime,
+      dataUrl: url,
+    }
+    const cursor = prompt.cursor() ?? getCursorPosition(editor)
+    prompt.set([...prompt.current(), attachment], cursor)
+    return true
   }
 
-  const removeImageAttachment = (id: string) => {
+  const addAttachment = (file: File) => add(file)
+
+  const removeAttachment = (id: string) => {
     const current = prompt.current()
     const next = current.filter((part) => part.type !== "image" || part.id !== id)
     prompt.set(next, prompt.cursor())
@@ -72,21 +100,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
 
     const items = Array.from(clipboardData.items)
     const fileItems = items.filter((item) => item.kind === "file")
-    const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
 
-    if (imageItems.length > 0) {
-      for (const item of imageItems) {
+    if (fileItems.length > 0) {
+      let found = false
+      for (const item of fileItems) {
         const file = item.getAsFile()
-        if (file) await addImageAttachment(file)
+        if (!file) continue
+        const ok = await add(file, false)
+        if (ok) found = true
       }
-      return
-    }
-
-    if (fileItems.length > 0) {
-      showToast({
-        title: language.t("prompt.toast.pasteUnsupported.title"),
-        description: language.t("prompt.toast.pasteUnsupported.description"),
-      })
+      if (!found) warn()
       return
     }
 
@@ -96,7 +119,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     if (input.readClipboardImage && !plainText) {
       const file = await input.readClipboardImage()
       if (file) {
-        await addImageAttachment(file)
+        await addAttachment(file)
         return
       }
     }
@@ -153,11 +176,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     const dropped = event.dataTransfer?.files
     if (!dropped) return
 
+    let found = false
     for (const file of Array.from(dropped)) {
-      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
-        await addImageAttachment(file)
-      }
+      const ok = await add(file, false)
+      if (ok) found = true
     }
+    if (!found && dropped.length > 0) warn()
   }
 
   onMount(() => {
@@ -173,8 +197,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   })
 
   return {
-    addImageAttachment,
-    removeImageAttachment,
+    addAttachment,
+    removeAttachment,
     handlePaste,
   }
 }

+ 119 - 0
packages/app/src/components/prompt-input/files.ts

@@ -0,0 +1,119 @@
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+
+const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
+const IMAGE_EXTS = new Map([
+  ["gif", "image/gif"],
+  ["jpeg", "image/jpeg"],
+  ["jpg", "image/jpeg"],
+  ["png", "image/png"],
+  ["webp", "image/webp"],
+])
+const TEXT_MIMES = new Set([
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+])
+
+export const ACCEPTED_FILE_TYPES = [
+  ...ACCEPTED_IMAGE_TYPES,
+  "application/pdf",
+  "text/*",
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+  ".c",
+  ".cc",
+  ".cjs",
+  ".conf",
+  ".cpp",
+  ".css",
+  ".csv",
+  ".cts",
+  ".env",
+  ".go",
+  ".gql",
+  ".graphql",
+  ".h",
+  ".hh",
+  ".hpp",
+  ".htm",
+  ".html",
+  ".ini",
+  ".java",
+  ".js",
+  ".json",
+  ".jsx",
+  ".log",
+  ".md",
+  ".mdx",
+  ".mjs",
+  ".mts",
+  ".py",
+  ".rb",
+  ".rs",
+  ".sass",
+  ".scss",
+  ".sh",
+  ".sql",
+  ".toml",
+  ".ts",
+  ".tsx",
+  ".txt",
+  ".xml",
+  ".yaml",
+  ".yml",
+  ".zsh",
+]
+
+const SAMPLE = 4096
+
+function kind(type: string) {
+  return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
+}
+
+function ext(name: string) {
+  const idx = name.lastIndexOf(".")
+  if (idx === -1) return ""
+  return name.slice(idx + 1).toLowerCase()
+}
+
+function textMime(type: string) {
+  if (!type) return false
+  if (type.startsWith("text/")) return true
+  if (TEXT_MIMES.has(type)) return true
+  if (type.endsWith("+json")) return true
+  return type.endsWith("+xml")
+}
+
+function textBytes(bytes: Uint8Array) {
+  if (bytes.length === 0) return true
+  let count = 0
+  for (const byte of bytes) {
+    if (byte === 0) return false
+    if (byte < 9 || (byte > 13 && byte < 32)) count += 1
+  }
+  return count / bytes.length <= 0.3
+}
+
+export async function attachmentMime(file: File) {
+  const type = kind(file.type)
+  if (IMAGE_MIMES.has(type)) return type
+  if (type === "application/pdf") return type
+
+  const suffix = ext(file.name)
+  const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
+  if ((!type || type === "application/octet-stream") && fallback) return fallback
+
+  if (textMime(type)) return "text/plain"
+  const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
+  if (!textBytes(bytes)) return
+  return "text/plain"
+}

+ 3 - 3
packages/app/src/i18n/ar.ts

@@ -244,7 +244,7 @@ export const dict = {
   "prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
   "prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
   "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
-  "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
+  "prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
   "prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
   "prompt.slash.badge.custom": "مخصص",
   "prompt.slash.badge.skill": "مهارة",
@@ -257,8 +257,8 @@ export const dict = {
   "prompt.attachment.remove": "إزالة المرفق",
   "prompt.action.send": "إرسال",
   "prompt.action.stop": "توقف",
-  "prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
-  "prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
+  "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
+  "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
   "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
   "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
   "prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",

+ 3 - 3
packages/app/src/i18n/br.ts

@@ -244,7 +244,7 @@ export const dict = {
   "prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
   "prompt.popover.emptyResults": "Nenhum resultado correspondente",
   "prompt.popover.emptyCommands": "Nenhum comando correspondente",
-  "prompt.dropzone.label": "Solte imagens ou PDFs aqui",
+  "prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
   "prompt.dropzone.file.label": "Solte para @mencionar arquivo",
   "prompt.slash.badge.custom": "personalizado",
   "prompt.slash.badge.skill": "skill",
@@ -257,8 +257,8 @@ export const dict = {
   "prompt.attachment.remove": "Remover anexo",
   "prompt.action.send": "Enviar",
   "prompt.action.stop": "Parar",
-  "prompt.toast.pasteUnsupported.title": "Colagem não suportada",
-  "prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
+  "prompt.toast.pasteUnsupported.title": "Anexo não suportado",
+  "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
   "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
   "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
   "prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",

+ 3 - 3
packages/app/src/i18n/bs.ts

@@ -264,7 +264,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Nema rezultata",
   "prompt.popover.emptyCommands": "Nema komandi",
-  "prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
+  "prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
   "prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
   "prompt.slash.badge.custom": "prilagođeno",
   "prompt.slash.badge.skill": "skill",
@@ -278,8 +278,8 @@ export const dict = {
   "prompt.action.send": "Pošalji",
   "prompt.action.stop": "Zaustavi",
 
-  "prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
-  "prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
+  "prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
+  "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
   "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
   "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
   "prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",

+ 3 - 3
packages/app/src/i18n/da.ts

@@ -262,7 +262,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Ingen matchende resultater",
   "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
-  "prompt.dropzone.label": "Slip billeder eller PDF'er her",
+  "prompt.dropzone.label": "Slip billeder, PDF'er eller tekstfiler her",
   "prompt.dropzone.file.label": "Slip for at @nævne fil",
   "prompt.slash.badge.custom": "brugerdefineret",
   "prompt.slash.badge.skill": "skill",
@@ -276,8 +276,8 @@ export const dict = {
   "prompt.action.send": "Send",
   "prompt.action.stop": "Stop",
 
-  "prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt",
-  "prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.",
+  "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
+  "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
   "prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
   "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
   "prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",

+ 3 - 3
packages/app/src/i18n/de.ts

@@ -249,7 +249,7 @@ export const dict = {
   "prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?",
   "prompt.popover.emptyResults": "Keine passenden Ergebnisse",
   "prompt.popover.emptyCommands": "Keine passenden Befehle",
-  "prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
+  "prompt.dropzone.label": "Bilder, PDFs oder Textdateien hier ablegen",
   "prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
   "prompt.slash.badge.custom": "benutzerdefiniert",
   "prompt.slash.badge.skill": "Skill",
@@ -262,8 +262,8 @@ export const dict = {
   "prompt.attachment.remove": "Anhang entfernen",
   "prompt.action.send": "Senden",
   "prompt.action.stop": "Stopp",
-  "prompt.toast.pasteUnsupported.title": "Nicht unterstütztes Einfügen",
-  "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder oder PDFs eingefügt werden.",
+  "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
+  "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
   "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
   "prompt.toast.modelAgentRequired.description":
     "Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",

+ 3 - 3
packages/app/src/i18n/en.ts

@@ -264,7 +264,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "No matching results",
   "prompt.popover.emptyCommands": "No matching commands",
-  "prompt.dropzone.label": "Drop images or PDFs here",
+  "prompt.dropzone.label": "Drop images, PDFs, or text files here",
   "prompt.dropzone.file.label": "Drop to @mention file",
   "prompt.slash.badge.custom": "custom",
   "prompt.slash.badge.skill": "skill",
@@ -278,8 +278,8 @@ export const dict = {
   "prompt.action.send": "Send",
   "prompt.action.stop": "Stop",
 
-  "prompt.toast.pasteUnsupported.title": "Unsupported paste",
-  "prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.",
+  "prompt.toast.pasteUnsupported.title": "Unsupported attachment",
+  "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
   "prompt.toast.modelAgentRequired.title": "Select an agent and model",
   "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
   "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",

+ 3 - 3
packages/app/src/i18n/es.ts

@@ -263,7 +263,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Sin resultados coincidentes",
   "prompt.popover.emptyCommands": "Sin comandos coincidentes",
-  "prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
+  "prompt.dropzone.label": "Suelta imágenes, PDFs o archivos de texto aquí",
   "prompt.dropzone.file.label": "Suelta para @mencionar archivo",
   "prompt.slash.badge.custom": "personalizado",
   "prompt.slash.badge.skill": "skill",
@@ -277,8 +277,8 @@ export const dict = {
   "prompt.action.send": "Enviar",
   "prompt.action.stop": "Detener",
 
-  "prompt.toast.pasteUnsupported.title": "Pegado no soportado",
-  "prompt.toast.pasteUnsupported.description": "Solo se pueden pegar imágenes o PDFs aquí.",
+  "prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
+  "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
   "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
   "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
   "prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",

+ 4 - 3
packages/app/src/i18n/fr.ts

@@ -244,7 +244,7 @@ export const dict = {
   "prompt.example.25": "Comment fonctionnent les variables d'environnement ici ?",
   "prompt.popover.emptyResults": "Aucun résultat correspondant",
   "prompt.popover.emptyCommands": "Aucune commande correspondante",
-  "prompt.dropzone.label": "Déposez des images ou des PDF ici",
+  "prompt.dropzone.label": "Déposez des images, des PDF ou des fichiers texte ici",
   "prompt.dropzone.file.label": "Déposez pour @mentionner le fichier",
   "prompt.slash.badge.custom": "personnalisé",
   "prompt.slash.badge.skill": "skill",
@@ -257,8 +257,9 @@ export const dict = {
   "prompt.attachment.remove": "Supprimer la pièce jointe",
   "prompt.action.send": "Envoyer",
   "prompt.action.stop": "Arrêter",
-  "prompt.toast.pasteUnsupported.title": "Collage non supporté",
-  "prompt.toast.pasteUnsupported.description": "Seules les images ou les PDF peuvent être collés ici.",
+  "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
+  "prompt.toast.pasteUnsupported.description":
+    "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
   "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
   "prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer un message.",
   "prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",

+ 3 - 3
packages/app/src/i18n/ja.ts

@@ -243,7 +243,7 @@ export const dict = {
   "prompt.example.25": "ここでは環境変数はどう機能しますか?",
   "prompt.popover.emptyResults": "一致する結果がありません",
   "prompt.popover.emptyCommands": "一致するコマンドがありません",
-  "prompt.dropzone.label": "画像またはPDFをここにドロップ",
+  "prompt.dropzone.label": "画像、PDF、またはテキストファイルをここにドロップしてください",
   "prompt.dropzone.file.label": "ドロップして@メンションファイルを追加",
   "prompt.slash.badge.custom": "カスタム",
   "prompt.slash.badge.skill": "スキル",
@@ -256,8 +256,8 @@ export const dict = {
   "prompt.attachment.remove": "添付ファイルを削除",
   "prompt.action.send": "送信",
   "prompt.action.stop": "停止",
-  "prompt.toast.pasteUnsupported.title": "サポートされていない貼り付け",
-  "prompt.toast.pasteUnsupported.description": "ここでは画像またはPDFのみ貼り付け可能です。",
+  "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
+  "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
   "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
   "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
   "prompt.toast.worktreeCreateFailed.title": "ワークツリーの作成に失敗しました",

+ 3 - 3
packages/app/src/i18n/ko.ts

@@ -247,7 +247,7 @@ export const dict = {
   "prompt.example.25": "여기서 환경 변수는 어떻게 작동하나요?",
   "prompt.popover.emptyResults": "일치하는 결과 없음",
   "prompt.popover.emptyCommands": "일치하는 명령어 없음",
-  "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
+  "prompt.dropzone.label": "이미지, PDF 또는 텍스트 파일을 이곳에 드롭하세요",
   "prompt.dropzone.file.label": "드롭하여 파일 @멘션 추가",
   "prompt.slash.badge.custom": "사용자 지정",
   "prompt.slash.badge.skill": "스킬",
@@ -260,8 +260,8 @@ export const dict = {
   "prompt.attachment.remove": "첨부 파일 제거",
   "prompt.action.send": "전송",
   "prompt.action.stop": "중지",
-  "prompt.toast.pasteUnsupported.title": "지원되지 않는 붙여넣기",
-  "prompt.toast.pasteUnsupported.description": "이미지나 PDF만 붙여넣을 수 있습니다.",
+  "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
+  "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
   "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
   "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
   "prompt.toast.worktreeCreateFailed.title": "작업 트리 생성 실패",

+ 3 - 3
packages/app/src/i18n/no.ts

@@ -266,7 +266,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Ingen matchende resultater",
   "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
-  "prompt.dropzone.label": "Slipp bilder eller PDF-er her",
+  "prompt.dropzone.label": "Slipp bilder, PDF-er eller tekstfiler her",
   "prompt.dropzone.file.label": "Slipp for å @nevne fil",
   "prompt.slash.badge.custom": "egendefinert",
   "prompt.slash.badge.skill": "skill",
@@ -280,8 +280,8 @@ export const dict = {
   "prompt.action.send": "Send",
   "prompt.action.stop": "Stopp",
 
-  "prompt.toast.pasteUnsupported.title": "Liming ikke støttet",
-  "prompt.toast.pasteUnsupported.description": "Kun bilder eller PDF-er kan limes inn her.",
+  "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
+  "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
   "prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
   "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
   "prompt.toast.worktreeCreateFailed.title": "Kunne ikke opprette worktree",

+ 3 - 3
packages/app/src/i18n/pl.ts

@@ -245,7 +245,7 @@ export const dict = {
   "prompt.example.25": "Jak działają tutaj zmienne środowiskowe?",
   "prompt.popover.emptyResults": "Brak pasujących wyników",
   "prompt.popover.emptyCommands": "Brak pasujących poleceń",
-  "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
+  "prompt.dropzone.label": "Upuść tutaj obrazy, pliki PDF lub pliki tekstowe",
   "prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik",
   "prompt.slash.badge.custom": "własne",
   "prompt.slash.badge.skill": "skill",
@@ -258,8 +258,8 @@ export const dict = {
   "prompt.attachment.remove": "Usuń załącznik",
   "prompt.action.send": "Wyślij",
   "prompt.action.stop": "Zatrzymaj",
-  "prompt.toast.pasteUnsupported.title": "Nieobsługiwane wklejanie",
-  "prompt.toast.pasteUnsupported.description": "Tylko obrazy lub pliki PDF mogą być tutaj wklejane.",
+  "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
+  "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
   "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
   "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
   "prompt.toast.worktreeCreateFailed.title": "Nie udało się utworzyć drzewa roboczego",

+ 3 - 3
packages/app/src/i18n/ru.ts

@@ -263,7 +263,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Нет совпадений",
   "prompt.popover.emptyCommands": "Нет совпадающих команд",
-  "prompt.dropzone.label": "Перетащите изображения или PDF сюда",
+  "prompt.dropzone.label": "Перетащите сюда изображения, PDF или текстовые файлы",
   "prompt.dropzone.file.label": "Отпустите для @упоминания файла",
   "prompt.slash.badge.custom": "своё",
   "prompt.slash.badge.skill": "навык",
@@ -277,8 +277,8 @@ export const dict = {
   "prompt.action.send": "Отправить",
   "prompt.action.stop": "Остановить",
 
-  "prompt.toast.pasteUnsupported.title": "Неподдерживаемая вставка",
-  "prompt.toast.pasteUnsupported.description": "Сюда можно вставлять только изображения или PDF.",
+  "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
+  "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
   "prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
   "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
   "prompt.toast.worktreeCreateFailed.title": "Не удалось создать worktree",

+ 3 - 3
packages/app/src/i18n/th.ts

@@ -263,7 +263,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน",
   "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
-  "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
+  "prompt.dropzone.label": "ลากรูปภาพ, PDF หรือไฟล์ข้อความมาวางที่นี่",
   "prompt.dropzone.file.label": "วางเพื่อ @กล่าวถึงไฟล์",
   "prompt.slash.badge.custom": "กำหนดเอง",
   "prompt.slash.badge.skill": "ทักษะ",
@@ -277,8 +277,8 @@ export const dict = {
   "prompt.action.send": "ส่ง",
   "prompt.action.stop": "หยุด",
 
-  "prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ",
-  "prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น",
+  "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
+  "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
   "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
   "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
   "prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree",

+ 3 - 3
packages/app/src/i18n/tr.ts

@@ -268,7 +268,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Eşleşen sonuç yok",
   "prompt.popover.emptyCommands": "Eşleşen komut yok",
-  "prompt.dropzone.label": "Görsel veya PDF'leri buraya bırakın",
+  "prompt.dropzone.label": "Resimleri, PDF'leri veya metin dosyalarını buraya bırakın",
   "prompt.dropzone.file.label": "@bahsetmek için dosyayı bırakın",
   "prompt.slash.badge.custom": "özel",
   "prompt.slash.badge.skill": "beceri",
@@ -282,8 +282,8 @@ export const dict = {
   "prompt.action.send": "Gönder",
   "prompt.action.stop": "Durdur",
 
-  "prompt.toast.pasteUnsupported.title": "Desteklenmeyen yapıştırma",
-  "prompt.toast.pasteUnsupported.description": "Buraya sadece görsel veya PDF yapıştırılabilir.",
+  "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
+  "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
   "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
   "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
   "prompt.toast.worktreeCreateFailed.title": "Çalışma ağacı oluşturulamadı",

+ 3 - 3
packages/app/src/i18n/zh.ts

@@ -283,7 +283,7 @@ export const dict = {
   "prompt.example.25": "这里的环境变量是怎么工作的?",
   "prompt.popover.emptyResults": "没有匹配的结果",
   "prompt.popover.emptyCommands": "没有匹配的命令",
-  "prompt.dropzone.label": "将图片或 PDF 拖到这里",
+  "prompt.dropzone.label": "将图片、PDF 或文本文件拖放到此处",
   "prompt.dropzone.file.label": "拖放以 @提及文件",
   "prompt.slash.badge.custom": "自定义",
   "prompt.slash.badge.skill": "技能",
@@ -296,8 +296,8 @@ export const dict = {
   "prompt.attachment.remove": "移除附件",
   "prompt.action.send": "发送",
   "prompt.action.stop": "停止",
-  "prompt.toast.pasteUnsupported.title": "不支持的粘贴",
-  "prompt.toast.pasteUnsupported.description": "这里只能粘贴图片或 PDF 文件。",
+  "prompt.toast.pasteUnsupported.title": "不支持的附件",
+  "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
   "prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
   "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
   "prompt.toast.worktreeCreateFailed.title": "创建工作树失败",

+ 3 - 3
packages/app/src/i18n/zht.ts

@@ -263,7 +263,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "沒有符合的結果",
   "prompt.popover.emptyCommands": "沒有符合的命令",
-  "prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
+  "prompt.dropzone.label": "將圖片、PDF 或文字檔案拖放到此處",
   "prompt.dropzone.file.label": "拖放以 @提及檔案",
   "prompt.slash.badge.custom": "自訂",
   "prompt.slash.badge.skill": "技能",
@@ -277,8 +277,8 @@ export const dict = {
   "prompt.action.send": "傳送",
   "prompt.action.stop": "停止",
 
-  "prompt.toast.pasteUnsupported.title": "不支援的貼上",
-  "prompt.toast.pasteUnsupported.description": "這裡只能貼上圖片或 PDF 檔案。",
+  "prompt.toast.pasteUnsupported.title": "不支援的附件",
+  "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
   "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
   "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
   "prompt.toast.worktreeCreateFailed.title": "建立工作樹失敗",

+ 2 - 1
packages/opencode/src/session/prompt.ts

@@ -47,6 +47,7 @@ import { LLM } from "./llm"
 import { iife } from "@/util/iife"
 import { Shell } from "@/shell/shell"
 import { Truncate } from "@/tool/truncation"
+import { decodeDataUrl } from "@/util/data-url"
 
 // @ts-ignore
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1079,7 +1080,7 @@ export namespace SessionPrompt {
                     sessionID: input.sessionID,
                     type: "text",
                     synthetic: true,
-                    text: Buffer.from(part.url, "base64url").toString(),
+                    text: decodeDataUrl(part.url),
                   },
                   {
                     ...part,

+ 9 - 0
packages/opencode/src/util/data-url.ts

@@ -0,0 +1,9 @@
+export function decodeDataUrl(url: string) {
+  const idx = url.indexOf(",")
+  if (idx === -1) return ""
+
+  const head = url.slice(0, idx)
+  const body = url.slice(idx + 1)
+  if (head.includes(";base64")) return Buffer.from(body, "base64").toString("utf8")
+  return decodeURIComponent(body)
+}

+ 14 - 0
packages/opencode/test/util/data-url.test.ts

@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test"
+import { decodeDataUrl } from "../../src/util/data-url"
+
+describe("decodeDataUrl", () => {
+  test("decodes base64 data URLs", () => {
+    const body = '{\n  "ok": true\n}\n'
+    const url = `data:text/plain;base64,${Buffer.from(body).toString("base64")}`
+    expect(decodeDataUrl(url)).toBe(body)
+  })
+
+  test("decodes plain data URLs", () => {
+    expect(decodeDataUrl("data:text/plain,hello%20world")).toBe("hello world")
+  })
+})

+ 55 - 0
packages/ui/src/components/message-file.test.ts

@@ -0,0 +1,55 @@
+import { describe, expect, test } from "bun:test"
+import type { FilePart } from "@opencode-ai/sdk/v2"
+import { attached, inline, kind } from "./message-file"
+
+function file(part: Partial<FilePart> = {}): FilePart {
+  return {
+    id: "part_1",
+    sessionID: "ses_1",
+    messageID: "msg_1",
+    type: "file",
+    mime: "text/plain",
+    url: "file:///repo/README.txt",
+    filename: "README.txt",
+    ...part,
+  }
+}
+
+describe("message-file", () => {
+  test("treats data URLs as attachments", () => {
+    expect(attached(file({ url: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
+    expect(attached(file())).toBe(false)
+  })
+
+  test("treats only non-attachment source ranges as inline references", () => {
+    expect(
+      inline(
+        file({
+          source: {
+            type: "file",
+            path: "/repo/README.txt",
+            text: { value: "@README.txt", start: 0, end: 11 },
+          },
+        }),
+      ),
+    ).toBe(true)
+
+    expect(
+      inline(
+        file({
+          url: "data:text/plain;base64,SGVsbG8=",
+          source: {
+            type: "file",
+            path: "/repo/README.txt",
+            text: { value: "@README.txt", start: 0, end: 11 },
+          },
+        }),
+      ),
+    ).toBe(false)
+  })
+
+  test("separates image and file attachment kinds", () => {
+    expect(kind(file({ mime: "image/png" }))).toBe("image")
+    expect(kind(file({ mime: "application/pdf" }))).toBe("file")
+  })
+})

+ 14 - 0
packages/ui/src/components/message-file.ts

@@ -0,0 +1,14 @@
+import type { FilePart } from "@opencode-ai/sdk/v2"
+
+export function attached(part: FilePart) {
+  return part.url.startsWith("data:")
+}
+
+export function inline(part: FilePart) {
+  if (attached(part)) return false
+  return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
+}
+
+export function kind(part: FilePart) {
+  return part.mime.startsWith("image/") ? "image" : "file"
+}

+ 32 - 1
packages/ui/src/components/message-part.css

@@ -38,10 +38,12 @@
     flex-direction: column;
     align-items: center;
     justify-content: center;
+    min-width: 0;
     border-radius: 6px;
     overflow: hidden;
     background: var(--surface-weak);
     border: 1px solid var(--border-weak-base);
+    cursor: default;
     transition:
       border-color 0.15s ease,
       opacity 0.3s ease;
@@ -50,14 +52,19 @@
       border-color: var(--border-strong-base);
     }
 
+    &[data-clickable] {
+      cursor: pointer;
+    }
+
     &[data-type="image"] {
       width: 48px;
       height: 48px;
     }
 
     &[data-type="file"] {
-      width: 48px;
+      width: min(220px, 100%);
       height: 48px;
+      padding: 0 10px;
     }
   }
 
@@ -81,6 +88,30 @@
     }
   }
 
+  [data-slot="user-message-attachment-file"] {
+    width: 100%;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    [data-component="file-icon"] {
+      width: 20px;
+      height: 20px;
+      flex: none;
+    }
+  }
+
+  [data-slot="user-message-attachment-name"] {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    color: var(--text-base);
+    font-size: var(--font-size-small);
+    line-height: var(--line-height-large);
+  }
+
   [data-slot="user-message-body"] {
     width: fit-content;
     max-width: min(82%, 64ch);

+ 30 - 37
packages/ui/src/components/message-part.tsx

@@ -54,6 +54,7 @@ import { AnimatedCountList } from "./tool-count-summary"
 import { ToolStatusTitle } from "./tool-status-title"
 import { animate } from "motion"
 import { useLocation } from "@solidjs/router"
+import { attached, inline, kind } from "./message-file"
 
 function ShellSubmessage(props: { text: string; animate?: boolean }) {
   let widthRef: HTMLSpanElement | undefined
@@ -901,19 +902,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
 
   const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
 
-  const attachments = createMemo(() =>
-    files()?.filter((f) => {
-      const mime = f.mime
-      return mime.startsWith("image/") || mime === "application/pdf"
-    }),
-  )
+  const attachments = createMemo(() => files().filter(attached))
 
-  const inlineFiles = createMemo(() =>
-    files().filter((f) => {
-      const mime = f.mime
-      return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined
-    }),
-  )
+  const inlineFiles = createMemo(() => files().filter(inline))
 
   const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
 
@@ -973,32 +964,34 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
       <Show when={attachments().length > 0}>
         <div data-slot="user-message-attachments">
           <For each={attachments()}>
-            {(file) => (
-              <div
-                data-slot="user-message-attachment"
-                data-type={file.mime.startsWith("image/") ? "image" : "file"}
-                onClick={() => {
-                  if (file.mime.startsWith("image/") && file.url) {
-                    openImagePreview(file.url, file.filename)
-                  }
-                }}
-              >
-                <Show
-                  when={file.mime.startsWith("image/") && file.url}
-                  fallback={
-                    <div data-slot="user-message-attachment-icon">
-                      <Icon name="folder" />
-                    </div>
-                  }
+            {(file) => {
+              const type = kind(file)
+              const name = file.filename ?? i18n.t("ui.message.attachment.alt")
+
+              return (
+                <div
+                  data-slot="user-message-attachment"
+                  data-type={type}
+                  data-clickable={type === "image" ? "true" : undefined}
+                  title={type === "file" ? name : undefined}
+                  onClick={() => {
+                    if (type === "image") openImagePreview(file.url, name)
+                  }}
                 >
-                  <img
-                    data-slot="user-message-attachment-image"
-                    src={file.url}
-                    alt={file.filename ?? i18n.t("ui.message.attachment.alt")}
-                  />
-                </Show>
-              </div>
-            )}
+                  <Show
+                    when={type === "image"}
+                    fallback={
+                      <div data-slot="user-message-attachment-file">
+                        <FileIcon node={{ path: name, type: "file" }} />
+                        <span data-slot="user-message-attachment-name">{name}</span>
+                      </div>
+                    }
+                  >
+                    <img data-slot="user-message-attachment-image" src={file.url} alt={name} />
+                  </Show>
+                </div>
+              )
+            }}
           </For>
         </div>
       </Show>