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 {