summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-13 06:58:24 -0500
committerGitHub <[email protected]>2026-03-13 06:58:24 -0500
commit843f188aaafd3a19272f3867a686644d6a31c325 (patch)
treeef2834837ff44a86f7222ed6518328d0ab46b640
parent05cb3c87ca387be41aceb5ccad978c6848a56f70 (diff)
downloadopencode-843f188aaafd3a19272f3867a686644d6a31c325.tar.gz
opencode-843f188aaafd3a19272f3867a686644d6a31c325.zip
fix(app): support text attachments (#17335)
-rw-r--r--packages/app/src/components/prompt-input.tsx9
-rw-r--r--packages/app/src/components/prompt-input/attachments.test.ts24
-rw-r--r--packages/app/src/components/prompt-input/attachments.ts102
-rw-r--r--packages/app/src/components/prompt-input/files.ts119
-rw-r--r--packages/app/src/i18n/ar.ts6
-rw-r--r--packages/app/src/i18n/br.ts6
-rw-r--r--packages/app/src/i18n/bs.ts6
-rw-r--r--packages/app/src/i18n/da.ts6
-rw-r--r--packages/app/src/i18n/de.ts6
-rw-r--r--packages/app/src/i18n/en.ts6
-rw-r--r--packages/app/src/i18n/es.ts6
-rw-r--r--packages/app/src/i18n/fr.ts7
-rw-r--r--packages/app/src/i18n/ja.ts6
-rw-r--r--packages/app/src/i18n/ko.ts6
-rw-r--r--packages/app/src/i18n/no.ts6
-rw-r--r--packages/app/src/i18n/pl.ts6
-rw-r--r--packages/app/src/i18n/ru.ts6
-rw-r--r--packages/app/src/i18n/th.ts6
-rw-r--r--packages/app/src/i18n/tr.ts6
-rw-r--r--packages/app/src/i18n/zh.ts6
-rw-r--r--packages/app/src/i18n/zht.ts6
-rw-r--r--packages/opencode/src/session/prompt.ts3
-rw-r--r--packages/opencode/src/util/data-url.ts9
-rw-r--r--packages/opencode/test/util/data-url.test.ts14
-rw-r--r--packages/ui/src/components/message-file.test.ts55
-rw-r--r--packages/ui/src/components/message-file.ts14
-rw-r--r--packages/ui/src/components/message-part.css33
-rw-r--r--packages/ui/src/components/message-part.tsx67
28 files changed, 419 insertions, 133 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index ac5beed69..5d3f5bd99 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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 = ""
}}
/>
diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts
new file mode 100644
index 000000000..d8ae43d13
--- /dev/null
+++ b/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()
+ })
+})
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
index a9e4e4965..b465ea5db 100644
--- a/packages/app/src/components/prompt-input/attachments.ts
+++ b/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,
}
}
diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts
new file mode 100644
index 000000000..594991d07
--- /dev/null
+++ b/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"
+}
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 99a2d03d0..720045a4d 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/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": "فشل إنشاء شجرة العمل",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 46ee7f114..a7d7433b0 100644
--- a/packages/app/src/i18n/br.ts
+++ b/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",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index 140b83810..ccdf2b604 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/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",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 9b776c143..f1701094b 100644
--- a/packages/app/src/i18n/da.ts
+++ b/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",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 5031748b4..2dfeed720 100644
--- a/packages/app/src/i18n/de.ts
+++ b/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.",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 65e878b4e..ad12e1e0d 100644
--- a/packages/app/src/i18n/en.ts
+++ b/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",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 2fabd6d4c..1cd47dfc7 100644
--- a/packages/app/src/i18n/es.ts
+++ b/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",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index dc30a0e53..c7d89c325 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/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",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 1f5615c9b..267411083 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/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": "ワークツリーの作成に失敗しました",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index a2f5e5c7c..bb57f9939 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/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": "작업 트리 생성 실패",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index ed75e556e..83d6a9903 100644
--- a/packages/app/src/i18n/no.ts
+++ b/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",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 2507acd9d..db9ef1800 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/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",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 6145b3011..e1abb6e6c 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/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",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 9cc3c5be1..b522e4631 100644
--- a/packages/app/src/i18n/th.ts
+++ b/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",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index 373f26ad6..8542dff79 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/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ı",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 819e1cd87..e762ba78d 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/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": "创建工作树失败",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 8c80cd323..184c789ce 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/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": "建立工作樹失敗",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 55b95fffe..743537f59 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/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,
diff --git a/packages/opencode/src/util/data-url.ts b/packages/opencode/src/util/data-url.ts
new file mode 100644
index 000000000..0fafcbc63
--- /dev/null
+++ b/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)
+}
diff --git a/packages/opencode/test/util/data-url.test.ts b/packages/opencode/test/util/data-url.test.ts
new file mode 100644
index 000000000..b8148285c
--- /dev/null
+++ b/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")
+ })
+})
diff --git a/packages/ui/src/components/message-file.test.ts b/packages/ui/src/components/message-file.test.ts
new file mode 100644
index 000000000..7bdf00763
--- /dev/null
+++ b/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")
+ })
+})
diff --git a/packages/ui/src/components/message-file.ts b/packages/ui/src/components/message-file.ts
new file mode 100644
index 000000000..ecc745690
--- /dev/null
+++ b/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"
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index f01408a38..5a325693b 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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);
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index b580998b6..e8c9dcf95 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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>