summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
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 /packages/app/src/components
parent05cb3c87ca387be41aceb5ccad978c6848a56f70 (diff)
downloadopencode-843f188aaafd3a19272f3867a686644d6a31c325.tar.gz
opencode-843f188aaafd3a19272f3867a686644d6a31c325.zip
fix(app): support text attachments (#17335)
Diffstat (limited to 'packages/app/src/components')
-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
4 files changed, 211 insertions, 43 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"
+}