From 843f188aaafd3a19272f3867a686644d6a31c325 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:58:24 -0500 Subject: [PATCH] fix(app): support text attachments (#17335) --- packages/app/src/components/prompt-input.tsx | 9 +- .../prompt-input/attachments.test.ts | 24 ++++ .../components/prompt-input/attachments.ts | 108 +++++++++------- .../app/src/components/prompt-input/files.ts | 119 ++++++++++++++++++ packages/app/src/i18n/ar.ts | 6 +- packages/app/src/i18n/br.ts | 6 +- packages/app/src/i18n/bs.ts | 6 +- packages/app/src/i18n/da.ts | 6 +- packages/app/src/i18n/de.ts | 6 +- packages/app/src/i18n/en.ts | 6 +- packages/app/src/i18n/es.ts | 6 +- packages/app/src/i18n/fr.ts | 7 +- packages/app/src/i18n/ja.ts | 6 +- packages/app/src/i18n/ko.ts | 6 +- packages/app/src/i18n/no.ts | 6 +- packages/app/src/i18n/pl.ts | 6 +- packages/app/src/i18n/ru.ts | 6 +- packages/app/src/i18n/th.ts | 6 +- packages/app/src/i18n/tr.ts | 6 +- packages/app/src/i18n/zh.ts | 6 +- packages/app/src/i18n/zht.ts | 6 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/util/data-url.ts | 9 ++ packages/opencode/test/util/data-url.test.ts | 14 +++ .../ui/src/components/message-file.test.ts | 55 ++++++++ packages/ui/src/components/message-file.ts | 14 +++ packages/ui/src/components/message-part.css | 33 ++++- packages/ui/src/components/message-part.tsx | 67 +++++----- 28 files changed, 422 insertions(+), 136 deletions(-) create mode 100644 packages/app/src/components/prompt-input/attachments.test.ts create mode 100644 packages/app/src/components/prompt-input/files.ts create mode 100644 packages/opencode/src/util/data-url.ts create mode 100644 packages/opencode/test/util/data-url.test.ts create mode 100644 packages/ui/src/components/message-file.test.ts create mode 100644 packages/ui/src/components/message-file.ts 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 = (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 = (props) => { onOpen={(attachment) => dialog.show(() => ) } - onRemove={removeImageAttachment} + onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} />
= (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((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 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) - } - reader.readAsDataURL(file) + const warn = () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) } - const removeImageAttachment = (id: string) => { + const add = async (file: File, toast = true) => { + const mime = await attachmentMime(file) + if (!mime) { + if (toast) warn() + return false + } + + 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 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) { - const file = item.getAsFile() - if (file) await addImageAttachment(file) - } - return - } if (fileItems.length > 0) { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) + 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() 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 { + 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 0}>
- {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } + {(file) => { + const type = kind(file) + const name = file.filename ?? i18n.t("ui.message.attachment.alt") + + return ( +
{ + if (type === "image") openImagePreview(file.url, name) + }} > - {file.filename - -
- )} + + + {name} +
+ } + > + {name} +
+
+ ) + }}