From a68443c559b4b15ce462c167b97a60521823758a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 22 Jul 2026 11:54:03 +0300 Subject: [PATCH] feat: expand chat attachment processing Add picker and MIME support for more source-code, diff, notebook, structured-log, SVG, and Draw.io formats. Convert Jupyter notebooks into readable text while omitting binary outputs, and sanitize HAR credentials, cookies, sensitive query parameters, and request/response bodies before files enter chat state. Convert HEIC and HEIF images to JPEG up front, centralize attachment preparation in a focused module, and cover the new validation and transformation behavior with regression tests. --- packages/ui/src/components/chat/ChatInput.tsx | 25 +- packages/ui/src/sync/DOCUMENTATION.md | 3 +- packages/ui/src/sync/attachment-files.test.ts | 112 ++++++ packages/ui/src/sync/attachment-files.ts | 343 ++++++++++++++++++ packages/ui/src/sync/input-store.ts | 146 +------- 5 files changed, 479 insertions(+), 150 deletions(-) create mode 100644 packages/ui/src/sync/attachment-files.test.ts create mode 100644 packages/ui/src/sync/attachment-files.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 25e374b1..86f7e7e2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -10,7 +10,8 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; -import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, useInputStore } from '@/sync/input-store'; +import { useInputStore } from '@/sync/input-store'; +import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; @@ -1215,8 +1216,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return () => observer.disconnect(); }, []); - const sendableAttachedFiles = attachedFiles; - const knownAgentNames = React.useMemo( () => new Set(agents.map((agent) => agent.name.toLowerCase())), [agents] @@ -1317,18 +1316,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, [inputMode, message, knownAgentNames]); const attachmentCitationRanges = React.useMemo(() => { - if (!message || !message.includes('[') || inputMode === 'shell' || sendableAttachedFiles.length === 0) { + if (!message || !message.includes('[') || inputMode === 'shell' || attachedFiles.length === 0) { return []; } return findAttachmentCitationRanges( message, - sendableAttachedFiles.map((file) => file.filename), + attachedFiles.map((file) => file.filename), ).map((range) => ({ ...range, style: 'mentionFile' as const, })); - }, [inputMode, message, sendableAttachedFiles]); + }, [attachedFiles, inputMode, message]); // Combined source-mode highlight: markdown syntax + @mentions. Returns null // when there's nothing to highlight so the overlay stays off for plain text. @@ -1757,7 +1756,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }, [pendingInputText, consumePendingInputText]); - const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts; + const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; @@ -1767,9 +1766,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const currentMessage = textareaRef.current?.value ?? message; return { message: currentMessage, - hasContent: currentMessage.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts, + hasContent: currentMessage.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, }; - }, [hasDrafts, message, sendableAttachedFiles.length]); + }, [attachedFiles.length, hasDrafts, message]); // Keep a ref to handleSubmit so callbacks don't depend on it. type SubmitOptions = { @@ -1794,7 +1793,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (drafts.length > 0) { messageToQueue = appendInlineComments(messageToQueue, drafts); } - const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles); + const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); addToQueue(messageQueueTarget, { content: messageToQueue, @@ -1819,7 +1818,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!isMobile) { textareaRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1856,7 +1855,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const inputSnapshot = options?.presetText != null ? { message: options.presetText, - hasContent: options.presetText.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts, + hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, } : getCurrentInputSnapshot(); const queuedMessagesToSend = queuedMessageId @@ -1958,7 +1957,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); - const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles); + const attachmentsToSend = sanitizeAttachmentsForSend(attachedFiles); addMentionedSkills(messageText); if (!agentMentionName && mention?.name) { diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index fc99e12d..62262663 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -47,11 +47,12 @@ So: | `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | | `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | +| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes | | `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | | `selection-store.ts` | Model/agent/variant selections | App UI state | | `voice-store.ts` | Voice state | App UI state | -Local chat attachments are normalized before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Browser and VS Code pickers expose the same OpenCode-compatible extension allowlist, while drag-and-drop may still accept an unknown extension after content inspection. +Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. ## Session list rules diff --git a/packages/ui/src/sync/attachment-files.test.ts b/packages/ui/src/sync/attachment-files.test.ts new file mode 100644 index 00000000..ab4b6a49 --- /dev/null +++ b/packages/ui/src/sync/attachment-files.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, mock, test } from "bun:test" +import { + ACCEPTED_ATTACHMENT_EXTENSIONS, + ATTACHMENT_ACCEPT, + prepareAttachmentFile, +} from "./attachment-files" + +mock.module("heic2any", () => ({ + default: async () => new Blob(["jpeg-data"], { type: "image/jpeg" }), +})) + +const prepare = (file: File) => Promise.resolve(prepareAttachmentFile(file)) + +describe("attachment file preparation", () => { + test("exposes the expanded code and structured-text formats to pickers", () => { + for (const extension of [ + "diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio", + "vue", "svelte", "php", "cs", "kt", "swift", "lua", "dart", "tf", "hcl", "proto", + ]) { + expect(ACCEPTED_ATTACHMENT_EXTENSIONS.includes(extension)).toBe(true) + expect(ATTACHMENT_ACCEPT.includes(`.${extension}`)).toBe(true) + } + }) + + test("renders notebooks as readable markdown without binary outputs", async () => { + const notebook = { + metadata: { kernelspec: { language: "python" } }, + cells: [ + { cell_type: "markdown", source: ["# Analysis\n", "Notes"] }, + { + cell_type: "code", + source: ["print('ok')"], + outputs: [ + { text: ["ok\n"] }, + { data: { "text/plain": ["result"], "image/png": "base64-image" } }, + ], + }, + ], + } + + const result = await prepare(new File([JSON.stringify(notebook)], "analysis.ipynb", { type: "application/json" })) + const text = await result?.file.text() + + expect(result?.mimeType).toBe("text/plain") + expect(text?.includes("# Notebook: analysis.ipynb")).toBe(true) + expect(text?.includes("```python\nprint('ok')\n```")).toBe(true) + expect(text?.includes("ok")).toBe(true) + expect(text?.includes("[Non-text output omitted: image/png]")).toBe(true) + expect(text?.includes("base64-image")).toBe(false) + }) + + test("redacts credentials and omits bodies from HAR files", async () => { + const har = { + log: { + entries: [{ + request: { + url: "https://example.com/api?token=secret&query=visible", + headers: [ + { name: "Authorization", value: "Bearer secret" }, + { name: "Accept", value: "application/json" }, + ], + cookies: [{ name: "session", value: "cookie-secret" }], + postData: { mimeType: "application/json", text: "{\"password\":\"secret\"}" }, + }, + response: { + headers: [{ name: "Set-Cookie", value: "session=secret" }], + content: { mimeType: "application/json", encoding: "base64", text: "secret response" }, + }, + }], + }, + } + + const result = await prepare(new File([JSON.stringify(har)], "network.har", { type: "application/json" })) + const text = await result?.file.text() ?? "" + const sanitized = JSON.parse(text) + const entry = sanitized.log.entries[0] + + expect(result?.mimeType).toBe("text/plain") + expect(new URL(entry.request.url).searchParams.get("token")).toBe("[REDACTED]") + expect(entry.request.headers[0].value).toBe("[REDACTED]") + expect(entry.request.headers[1].value).toBe("application/json") + expect(entry.request.cookies[0].value).toBe("[REDACTED]") + expect(entry.request.postData.text).toBe("[OMITTED BY OPENCHAMBER]") + expect(entry.response.headers[0].value).toBe("[REDACTED]") + expect(entry.response.content.text).toBe("[OMITTED BY OPENCHAMBER]") + expect(entry.response.content.encoding).toBe("[OMITTED BY OPENCHAMBER]") + expect(text.includes("Bearer secret")).toBe(false) + expect(text.includes("secret response")).toBe(false) + }) + + test("rejects malformed HAR files instead of leaking unsanitized content", async () => { + const result = await prepare(new File(["not valid HAR JSON"], "network.har", { type: "text/plain" })) + expect(result).toBe(undefined) + }) + + test("treats SVG and Draw.io files as text", async () => { + const svg = await prepare(new File([""], "diagram.svg", { type: "image/svg+xml" })) + const drawio = await prepare(new File([""], "diagram.drawio", { type: "application/xml" })) + + expect(svg?.mimeType).toBe("text/plain") + expect(drawio?.mimeType).toBe("text/plain") + }) + + test("converts HEIC files to JPEG before attachment", async () => { + const result = await prepare(new File(["heic-data"], "photo.heic", { type: "image/heic" })) + + expect(result?.mimeType).toBe("image/jpeg") + expect(result?.file.name).toBe("photo.jpg") + expect(result?.file.type).toBe("image/jpeg") + expect(await result?.file.text()).toBe("jpeg-data") + }) +}) diff --git a/packages/ui/src/sync/attachment-files.ts b/packages/ui/src/sync/attachment-files.ts new file mode 100644 index 00000000..01fbb129 --- /dev/null +++ b/packages/ui/src/sync/attachment-files.ts @@ -0,0 +1,343 @@ +const ACCEPTED_ATTACHMENT_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".bat", + ".c", + ".cc", + ".cjs", + ".cmd", + ".conf", + ".cpp", + ".cs", + ".css", + ".csv", + ".cts", + ".dart", + ".diff", + ".drawio", + ".env", + ".erl", + ".ex", + ".exs", + ".fs", + ".fsx", + ".go", + ".gql", + ".graphql", + ".h", + ".har", + ".hh", + ".hcl", + ".heic", + ".heif", + ".hpp", + ".hrl", + ".htm", + ".html", + ".ini", + ".ipynb", + ".java", + ".jl", + ".js", + ".json", + ".jsonl", + ".jsx", + ".kt", + ".kts", + ".log", + ".lua", + ".md", + ".mdx", + ".mjs", + ".mts", + ".ndjson", + ".patch", + ".php", + ".proto", + ".ps1", + ".py", + ".r", + ".rb", + ".rs", + ".sass", + ".scala", + ".scss", + ".sh", + ".sol", + ".sql", + ".svelte", + ".svg", + ".swift", + ".tf", + ".toml", + ".ts", + ".tsx", + ".txt", + ".vue", + ".xml", + ".yaml", + ".yml", + ".zig", + ".zsh", +] as const + +export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",") + +const PICKER_MIME_EXTENSIONS = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["image/heic", "heic"], + ["image/heif", "heif"], + ["application/pdf", "pdf"], + ["application/json", "json"], + ["application/ld+json", "jsonld"], + ["application/toml", "toml"], + ["application/x-toml", "toml"], + ["application/x-yaml", "yaml"], + ["application/xml", "xml"], + ["application/yaml", "yaml"], +]) +const TEXT_ATTACHMENT_EXTENSIONS = ["txt", "text", "md", "markdown", "log", "csv"] + +export const ACCEPTED_ATTACHMENT_EXTENSIONS = Array.from(new Set( + ACCEPTED_ATTACHMENT_TYPES.flatMap((type) => { + if (type.startsWith(".")) return [type.slice(1)] + if (type === "text/*") return TEXT_ATTACHMENT_EXTENSIONS + const extension = PICKER_MIME_EXTENSIONS.get(type) + return extension ? [extension] : [] + }) +)).sort() + +type OpenCodeAttachmentMimeType = + | "image/png" + | "image/jpeg" + | "image/gif" + | "image/webp" + | "application/pdf" + | "text/plain" + +const SUPPORTED_BINARY_MIMES = new Map([ + ["image/png", "image/png"], + ["image/jpeg", "image/jpeg"], + ["image/gif", "image/gif"], + ["image/webp", "image/webp"], + ["application/pdf", "application/pdf"], +]) +const SUPPORTED_BINARY_EXTENSIONS = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["pdf", "application/pdf"], + ["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", + "image/svg+xml", +]) +const ATTACHMENT_SAMPLE_BYTES = 4096 +const REDACTED = "[REDACTED]" +const OMITTED = "[OMITTED BY OPENCHAMBER]" +const SENSITIVE_NAMES = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|client[-_]?secret|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i + +export type PreparedAttachmentFile = { + file: File + mimeType: string +} + +const extensionOf = (name: string): string => { + const index = name.lastIndexOf(".") + return index === -1 ? "" : name.slice(index + 1).toLowerCase() +} + +const declaredMimeOf = (file: File): string => file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" + +const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => { + const bytes = new Uint8Array(await file.slice(0, ATTACHMENT_SAMPLE_BYTES).arrayBuffer()) + if (bytes.some((byte) => byte === 0)) return + const controlBytes = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length + if (bytes.length > 0 && controlBytes / bytes.length > 0.3) return + return "text/plain" +} + +const attachmentMime = ( + file: File, +): OpenCodeAttachmentMimeType | Promise<"text/plain" | undefined> | undefined => { + const type = declaredMimeOf(file) + const supportedMime = SUPPORTED_BINARY_MIMES.get(type) + if (supportedMime) return supportedMime + + const extension = extensionOf(file.name) + const fallback = SUPPORTED_BINARY_EXTENSIONS.get(extension) + if ((!type || type === "application/octet-stream") && fallback) return fallback + + if (type.startsWith("text/") || TEXT_MIMES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { + return "text/plain" + } + + return inspectTextContent(file) +} + +const sourceText = (source: unknown): string => { + if (typeof source === "string") return source + if (Array.isArray(source)) return source.filter((line): line is string => typeof line === "string").join("") + return "" +} + +const notebookText = (value: unknown, filename: string): string | undefined => { + if (!value || typeof value !== "object") return + const notebook = value as { cells?: unknown; metadata?: { kernelspec?: { language?: unknown } } } + if (!Array.isArray(notebook.cells)) return + const language = typeof notebook.metadata?.kernelspec?.language === "string" + ? notebook.metadata.kernelspec.language + : "" + const sections = [`# Notebook: ${filename}`] + + notebook.cells.forEach((rawCell, index) => { + if (!rawCell || typeof rawCell !== "object") return + const cell = rawCell as { cell_type?: unknown; source?: unknown; outputs?: unknown } + const content = sourceText(cell.source).trimEnd() + if (cell.cell_type === "markdown") { + sections.push(`## Markdown cell ${index + 1}\n\n${content}`) + return + } + if (cell.cell_type !== "code") return + + sections.push(`## Code cell ${index + 1}\n\n\`\`\`${language}\n${content}\n\`\`\``) + if (!Array.isArray(cell.outputs)) return + const outputs: string[] = [] + for (const rawOutput of cell.outputs) { + if (!rawOutput || typeof rawOutput !== "object") continue + const output = rawOutput as { text?: unknown; traceback?: unknown; data?: unknown; ename?: unknown; evalue?: unknown } + const text = sourceText(output.text) || sourceText(output.traceback) + if (text) { + outputs.push(text.trimEnd()) + continue + } + if (output.data && typeof output.data === "object") { + const data = output.data as Record + const plain = sourceText(data["text/plain"]) + if (plain) outputs.push(plain.trimEnd()) + const omitted = Object.keys(data).filter((type) => type !== "text/plain") + if (omitted.length > 0) outputs.push(`[Non-text output omitted: ${omitted.join(", ")}]`) + continue + } + if (typeof output.ename === "string" || typeof output.evalue === "string") { + outputs.push(`${String(output.ename ?? "Error")}: ${String(output.evalue ?? "")}`.trimEnd()) + } + } + if (outputs.length > 0) sections.push(`### Output\n\n${outputs.join("\n\n")}`) + }) + + return `${sections.join("\n\n")}\n` +} + +const redactUrl = (value: string): string => { + try { + const url = new URL(value) + for (const name of Array.from(url.searchParams.keys())) { + if (SENSITIVE_NAMES.test(name)) url.searchParams.set(name, REDACTED) + } + return url.toString() + } catch { + return value + } +} + +const sanitizeHarValue = (value: unknown, key?: string): unknown => { + if (key && SENSITIVE_NAMES.test(key)) return REDACTED + if (key === "cookies" && Array.isArray(value)) { + return value.map((cookie) => { + if (!cookie || typeof cookie !== "object") return cookie + return { ...(cookie as Record), value: REDACTED } + }) + } + if (key === "text" || key === "encoding") return OMITTED + if (typeof value === "string") return key === "url" ? redactUrl(value) : value + if (Array.isArray(value)) return value.map((item) => sanitizeHarValue(item)) + if (!value || typeof value !== "object") return value + + const record = value as Record + const sensitiveEntry = typeof record.name === "string" && SENSITIVE_NAMES.test(record.name) + return Object.fromEntries(Object.entries(record).map(([entryKey, entryValue]) => [ + entryKey, + sensitiveEntry && entryKey === "value" ? REDACTED : sanitizeHarValue(entryValue, entryKey), + ])) +} + +const prepareStructuredText = async (file: File, extension: string): Promise => { + const text = await file.text() + if (extension === "har") { + try { + const sanitized = sanitizeHarValue(JSON.parse(text)) + return new File([`${JSON.stringify(sanitized, null, 2)}\n`], file.name, { type: "text/plain" }) + } catch { + return + } + } + if (extension === "ipynb") { + try { + const rendered = notebookText(JSON.parse(text), file.name) + if (rendered) return new File([rendered], file.name, { type: "text/plain" }) + } catch { + // Invalid notebooks can still be useful as plain text. + } + } + return new File([text], file.name, { type: "text/plain" }) +} + +const convertHeicToJpeg = async (file: File): Promise => { + try { + const heic2any = (await import("heic2any")).default + const converted = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 }) + const blob = Array.isArray(converted) ? converted[0] : converted + if (!blob) return + const filename = file.name.replace(/\.(heic|heif)$/i, ".jpg") + return new File([blob], filename, { type: "image/jpeg" }) + } catch (error) { + console.warn("Failed to convert HEIC attachment to JPEG", error) + return + } +} + +export const prepareAttachmentFile = ( + file: File, +): PreparedAttachmentFile | Promise | undefined => { + const extension = extensionOf(file.name) + const type = declaredMimeOf(file) + if (type === "image/heic" || type === "image/heif" || extension === "heic" || extension === "heif") { + return convertHeicToJpeg(file).then((converted) => converted + ? { file: converted, mimeType: "image/jpeg" } + : undefined) + } + if (extension === "har" || extension === "ipynb") { + return prepareStructuredText(file, extension).then((prepared) => prepared + ? { file: prepared, mimeType: "text/plain" } + : undefined) + } + + const mime = attachmentMime(file) + if (typeof mime === "string") return { file, mimeType: mime } + return mime?.then((mimeType) => mimeType ? { file, mimeType } : undefined) +} diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index eff87a0f..3876378e 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -5,115 +5,12 @@ import { create } from "zustand" import type { AttachedFile } from "@/stores/types/sessionTypes" +import { prepareAttachmentFile } from "./attachment-files" const FILE_URI_PREFIX = "file://" const pendingVSCodeSelectionKeys = new Set() let attachmentReadGeneration = 0 -const ACCEPTED_ATTACHMENT_TYPES = [ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "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", -] as const - -export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",") - -const ATTACHMENT_MIME_EXTENSIONS = new Map([ - ["image/png", "png"], - ["image/jpeg", "jpg"], - ["image/gif", "gif"], - ["image/webp", "webp"], - ["application/pdf", "pdf"], - ["application/json", "json"], - ["application/ld+json", "jsonld"], - ["application/toml", "toml"], - ["application/x-toml", "toml"], - ["application/x-yaml", "yaml"], - ["application/xml", "xml"], - ["application/yaml", "yaml"], -]) -const TEXT_ATTACHMENT_EXTENSIONS = ["txt", "text", "md", "markdown", "log", "csv"] - -export const ACCEPTED_ATTACHMENT_EXTENSIONS = Array.from(new Set( - ACCEPTED_ATTACHMENT_TYPES.flatMap((type) => { - if (type.startsWith(".")) return [type.slice(1)] - if (type === "text/*") return TEXT_ATTACHMENT_EXTENSIONS - const extension = ATTACHMENT_MIME_EXTENSIONS.get(type) - return extension ? [extension] : [] - }) -)).sort() - -const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) -const IMAGE_EXTENSIONS = 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", -]) -const ATTACHMENT_SAMPLE_BYTES = 4096 - const encodeFilePath = (filepath: string): string => { let normalized = filepath.replace(/\\/g, "/") if (/^[A-Za-z]:/.test(normalized)) { @@ -150,30 +47,6 @@ const readFileAsDataUrl = (file: File, mime: string): Promise => new Pro reader.readAsDataURL(file) }) -const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => { - const bytes = new Uint8Array(await file.slice(0, ATTACHMENT_SAMPLE_BYTES).arrayBuffer()) - if (bytes.some((byte) => byte === 0)) return - const controlBytes = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length - if (bytes.length > 0 && controlBytes / bytes.length > 0.3) return - return "text/plain" -} - -const getAttachmentMime = (file: File): string | Promise<"text/plain" | undefined> | undefined => { - const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" - if (IMAGE_MIMES.has(type) || type === "application/pdf") return type - - const extensionIndex = file.name.lastIndexOf(".") - const extension = extensionIndex === -1 ? "" : file.name.slice(extensionIndex + 1).toLowerCase() - const fallback = IMAGE_EXTENSIONS.get(extension) ?? (extension === "pdf" ? "application/pdf" : undefined) - if ((!type || type === "application/octet-stream") && fallback) return fallback - - if (type.startsWith("text/") || TEXT_MIMES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { - return "text/plain" - } - - return inspectTextContent(file) -} - const getDataUrlByteSize = (url: string): number => { if (!url.startsWith("data:")) return 0 const commaIndex = url.indexOf(",") @@ -286,23 +159,24 @@ export const useInputStore = create()((set, get) => ({ addAttachedFile: async (file: File) => { const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` const generation = attachmentReadGeneration - const resolvedMime = getAttachmentMime(file) - const mimeType = typeof resolvedMime === "string" ? resolvedMime : await resolvedMime - if (!mimeType) return false + const preparedOrPending = prepareAttachmentFile(file) + // Keep the synchronous preparation path synchronous so FileReader starts before this action yields. + const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending + if (!prepared) return false let dataUrl: string try { - dataUrl = await readFileAsDataUrl(file, mimeType) + dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType) } catch { return false } if (!dataUrl || generation !== attachmentReadGeneration) return false const attached: AttachedFile = { id, - file, + file: prepared.file, dataUrl, - mimeType, - filename: file.name, - size: file.size, + mimeType: prepared.mimeType, + filename: prepared.file.name, + size: prepared.file.size, source: "local", } set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))