From d3a2564cf65acdf4d7d1789d8c18fc0f5cb55d40 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 22 Jul 2026 11:33:22 +0300 Subject: [PATCH] feat: normalize and filter chat attachments Attachment pickers now share an allowlist for supported file types Local attachments are normalized to consistent MIME types before upload VS Code file picker now respects extension filters and larger files are allowed --- packages/ui/src/components/chat/ChatInput.tsx | 18 ++- packages/ui/src/lib/api/types.ts | 2 +- packages/ui/src/sync/DOCUMENTATION.md | 2 + packages/ui/src/sync/input-store.test.ts | 77 +++++++++ packages/ui/src/sync/input-store.ts | 152 +++++++++++++++++- .../vscode/src/bridge-fs-helpers-runtime.ts | 4 +- packages/vscode/src/bridge-fs-runtime.ts | 7 +- packages/vscode/webview/api/vscode.ts | 4 +- 8 files changed, 245 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 6e054d8f..25e374b1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -10,7 +10,7 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; -import { useInputStore } from '@/sync/input-store'; +import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, useInputStore } from '@/sync/input-store'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; @@ -3716,14 +3716,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (files.length > 0) { + let attached = false; for (const file of files) { try { - await addAttachedFile(file); + attached = (await addAttachedFile(file)) || attached; } catch (error) { console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed')); } } + if (!attached) toast.error(t('chat.chatInput.toast.attachFileFailed')); } clearDropTextSuppression(); }; @@ -3744,20 +3745,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const attachFiles = React.useCallback(async (files: FileList | File[]) => { const list = Array.isArray(files) ? files : Array.from(files); + let attached = false; for (const file of list) { try { - await addAttachedFile(file); + attached = (await addAttachedFile(file)) || attached; } catch (error) { console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed')); } } + if (list.length > 0 && !attached) { + toast.error(t('chat.chatInput.toast.attachFileFailed')); + } }, [addAttachedFile, t]); const handleVSCodePickFiles = React.useCallback(async () => { try { - const data = (await vscodeApi?.pickFiles?.()) as { + const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as { files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; skipped?: Array<{ name?: string; reason?: string }>; } | undefined; @@ -5592,7 +5596,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo multiple className="hidden" onChange={handleLocalFileSelect} - accept="*/*" + accept={ATTACHMENT_ACCEPT} /> {/* Mobile attachment sheet: replaces the dropdown (which stole focus and diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 887e2d07..c35bd84e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -753,7 +753,7 @@ export interface VSCodeAPI { executeCommand(command: string, ...args: unknown[]): Promise; openAgentManager(): Promise; openExternalUrl(url: string): Promise; - pickFiles?(): Promise; + pickFiles?(options?: { extensions?: string[] }): Promise; saveImage?(payload: unknown): Promise; saveMarkdown?(payload: unknown): Promise; } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index c97bca34..fc99e12d 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -51,6 +51,8 @@ So: | `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. + ## Session list rules ### Directory bootstrap scheduling diff --git a/packages/ui/src/sync/input-store.test.ts b/packages/ui/src/sync/input-store.test.ts index 32a87087..12426600 100644 --- a/packages/ui/src/sync/input-store.test.ts +++ b/packages/ui/src/sync/input-store.test.ts @@ -135,4 +135,81 @@ describe("input-store attachments", () => { expect(useInputStore.getState().attachedFiles.map((attached) => attached.filename)).toEqual(["hello.txt"]) }) + + testWithMockFileReader("normalizes code files to text/plain", async () => { + const addPromise = useInputStore.getState().addAttachedFile( + new File(["const value = 1"], "example.ts", { type: "text/typescript" }) + ) + expect(pendingReaders).toHaveLength(1) + + resolveReader(pendingReaders[0], "data:text/typescript;base64,Y29uc3QgdmFsdWUgPSAx") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles[0]?.filename).toBe("example.ts") + expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain") + expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe( + "data:text/plain;base64,Y29uc3QgdmFsdWUgPSAx" + ) + }) + + testWithMockFileReader("normalizes structured text MIME types to text/plain", async () => { + const addPromise = useInputStore.getState().addAttachedFile( + new File(["{}"], "example.json", { type: "application/json" }) + ) + expect(pendingReaders).toHaveLength(1) + + resolveReader(pendingReaders[0], "data:application/json;base64,e30=") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain") + expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:text/plain;base64,e30=") + }) + + test("rejects an unknown binary file after inspecting its contents", async () => { + const attached = await useInputStore.getState().addAttachedFile( + new File([new Uint8Array([0, 1, 2, 3])], "archive.bin", { type: "application/octet-stream" }) + ) + + expect(attached).toBe(false) + expect(pendingReaders).toHaveLength(0) + expect(useInputStore.getState().attachedFiles).toEqual([]) + }) + + test("rejects unknown content with too many control bytes", async () => { + const attached = await useInputStore.getState().addAttachedFile( + new File([new Uint8Array([1, 2, 3, 65])], "encoded.custom", { type: "application/octet-stream" }) + ) + + expect(attached).toBe(false) + expect(pendingReaders).toHaveLength(0) + }) + + testWithMockFileReader("accepts an unknown MIME type when its contents are text", async () => { + const addPromise = useInputStore.getState().addAttachedFile( + new File(["custom text"], "example.custom", { type: "application/octet-stream" }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(pendingReaders).toHaveLength(1) + + resolveReader(pendingReaders[0], "data:application/octet-stream;base64,Y3VzdG9tIHRleHQ=") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain") + expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe( + "data:text/plain;base64,Y3VzdG9tIHRleHQ=" + ) + }) + + testWithMockFileReader("preserves supported image MIME types", async () => { + const addPromise = useInputStore.getState().addAttachedFile( + new File([new Uint8Array([1, 2, 3])], "image.webp", { type: "image/webp" }) + ) + expect(pendingReaders).toHaveLength(1) + + resolveReader(pendingReaders[0], "data:image/webp;base64,AQID") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp") + expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID") + }) }) diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index 52933de6..eff87a0f 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -10,6 +10,110 @@ 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)) { @@ -34,14 +138,42 @@ const toFileUrl = (filepath: string): string => { const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}` -const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { +const readFileAsDataUrl = (file: File, mime: string): Promise => new Promise((resolve, reject) => { const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) + reader.onload = () => { + const value = typeof reader.result === "string" ? reader.result : "" + const commaIndex = value.indexOf(",") + resolve(commaIndex === -1 ? value : `data:${mime};base64,${value.slice(commaIndex + 1)}`) + } reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")) reader.onabort = () => reject(new Error("File read aborted")) 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(",") @@ -103,7 +235,7 @@ export type InputState = { consumePendingPresetSubmit: () => string | null setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void consumePendingSyntheticParts: () => SyntheticContextPart[] | null - addAttachedFile: (file: File) => Promise + addAttachedFile: (file: File) => Promise removeAttachedFile: (id: string) => void setAttachedFiles: (files: AttachedFile[]) => void clearAttachedFiles: () => void @@ -154,23 +286,27 @@ 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 let dataUrl: string try { - dataUrl = await readFileAsDataUrl(file) + dataUrl = await readFileAsDataUrl(file, mimeType) } catch { - return + return false } - if (generation !== attachmentReadGeneration) return + if (!dataUrl || generation !== attachmentReadGeneration) return false const attached: AttachedFile = { id, file, dataUrl, - mimeType: file.type, + mimeType, filename: file.name, size: file.size, source: "local", } set((s) => ({ attachedFiles: [...s.attachedFiles, attached] })) + return true }, removeAttachedFile: (id) => @@ -221,7 +357,7 @@ export const useInputStore = create()((set, get) => ({ pendingVSCodeSelectionKeys.add(selectionKey) let dataUrl: string try { - dataUrl = await readFileAsDataUrl(file) + dataUrl = await readFileAsDataUrl(file, file.type) } catch { return } finally { diff --git a/packages/vscode/src/bridge-fs-helpers-runtime.ts b/packages/vscode/src/bridge-fs-helpers-runtime.ts index cdfdfea6..f506b8f4 100644 --- a/packages/vscode/src/bridge-fs-helpers-runtime.ts +++ b/packages/vscode/src/bridge-fs-helpers-runtime.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { execGit } from './bridge-git-process-runtime'; -const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; +const MAX_FILE_ATTACH_SIZE_BYTES = 20 * 1024 * 1024; const createGitCheckIgnoreTimeoutMs = () => { const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS); @@ -99,7 +99,7 @@ export const readUriAsAttachment = async ( const size = stat.size ?? 0; if (size > MAX_FILE_ATTACH_SIZE_BYTES) { - return { skipped: { name, reason: 'File exceeds 10MB limit' } }; + return { skipped: { name, reason: 'File exceeds 20MB limit' } }; } const bytes = await vscode.workspace.fs.readFile(uri); diff --git a/packages/vscode/src/bridge-fs-runtime.ts b/packages/vscode/src/bridge-fs-runtime.ts index 5e150bd0..80ac933a 100644 --- a/packages/vscode/src/bridge-fs-runtime.ts +++ b/packages/vscode/src/bridge-fs-runtime.ts @@ -481,7 +481,11 @@ export async function handleFsBridgeMessage( } case 'api:files/pick': { - const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; + const options = payload as { allowMany?: boolean; extensions?: unknown }; + const allowMany = options?.allowMany !== false; + const extensions = Array.isArray(options?.extensions) + ? options.extensions.filter((extension): extension is string => typeof extension === 'string' && extension.length > 0) + : []; const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; const picks = await vscode.window.showOpenDialog({ @@ -490,6 +494,7 @@ export async function handleFsBridgeMessage( canSelectMany: allowMany, defaultUri, openLabel: 'Attach', + filters: extensions.length > 0 ? { Files: extensions } : undefined, }); if (!picks || picks.length === 0) { diff --git a/packages/vscode/webview/api/vscode.ts b/packages/vscode/webview/api/vscode.ts index e1aaef80..951189c6 100644 --- a/packages/vscode/webview/api/vscode.ts +++ b/packages/vscode/webview/api/vscode.ts @@ -15,8 +15,8 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({ await openVSCodeExternalUrl(url); }, - async pickFiles(): Promise { - return sendBridgeMessage('api:files/pick'); + async pickFiles(options): Promise { + return sendBridgeMessage('api:files/pick', options); }, async saveImage(payload: unknown): Promise {