diff --git a/src/browser/features/ChatInput/AttachFileButton.tsx b/src/browser/features/ChatInput/AttachFileButton.tsx index 85c08410eb..d3f8996bb1 100644 --- a/src/browser/features/ChatInput/AttachFileButton.tsx +++ b/src/browser/features/ChatInput/AttachFileButton.tsx @@ -1,6 +1,6 @@ /** * Attach file button - floats inside the chat input textarea next to the voice input button. - * Opens a native file picker for images, SVGs, PDFs, and workspace-staged ZIPs. + * Opens a native file picker for images, SVGs, PDFs, JSON files, and workspace-staged ZIPs. */ import React, { useRef } from "react"; @@ -9,7 +9,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/To import { cn } from "@/common/lib/utils"; /** Accept filter for the file picker: provider attachments plus workspace-staged ZIPs. */ -const FILE_ACCEPT = "image/*,.svg,.pdf,.zip,application/zip,application/x-zip-compressed"; +const FILE_ACCEPT = + "image/*,.svg,.pdf,.json,.zip,application/json,application/zip,application/x-zip-compressed"; interface AttachFileButtonProps { onFiles: (files: File[]) => void; @@ -53,7 +54,7 @@ export const AttachFileButton: React.FC = (props) => { - Attach file — images, SVGs, PDFs, ZIPs + Attach file — images, SVGs, PDFs, JSON, ZIPs {/* Hidden file input — kept outside Tooltip to avoid stray DOM children */} diff --git a/src/browser/utils/attachmentsHandling.test.ts b/src/browser/utils/attachmentsHandling.test.ts index 411fa15e4a..4f21b24584 100644 --- a/src/browser/utils/attachmentsHandling.test.ts +++ b/src/browser/utils/attachmentsHandling.test.ts @@ -108,6 +108,20 @@ describe("attachmentsHandling", () => { expect(attachment.mediaType).toBe("image/jpeg"); expect(attachment.url).toContain("data:image/jpeg;base64,"); }); + + test("handles JSON files", async () => { + const blob = new Blob(['{"ok":true}'], { type: "application/json" }); + const file = new File([blob], "config.json", { type: "application/json" }); + + const attachment = await fileToChatAttachment(file); + + expect(attachment.kind).toBe("provider"); + if (attachment.kind !== "provider") throw new Error("Expected provider attachment"); + expect(attachment.mediaType).toBe("application/json"); + expect(attachment.filename).toBe("config.json"); + expect(attachment.url).toContain("data:application/json"); + expect(attachment.url).toContain(";base64,"); + }); }); describe("extractAttachmentsFromClipboard", () => { @@ -206,19 +220,21 @@ describe("attachmentsHandling", () => { const mockFile1 = new File(["image"], "photo.png", { type: "" }); // Empty type const mockFile2 = new File(["image"], "picture.jpg", { type: "" }); // Empty type const mockFile3 = new File(["pdf"], "doc.pdf", { type: "" }); // Empty type - const mockFile4 = new File(["text"], "document.txt", { type: "" }); // Empty type, unsupported + const mockFile4 = new File(['{"ok":true}'], "config.json", { type: "" }); // Empty type + const mockFile5 = new File(["text"], "document.txt", { type: "" }); // Empty type, unsupported const mockDataTransfer = { - files: [mockFile1, mockFile2, mockFile3, mockFile4], + files: [mockFile1, mockFile2, mockFile3, mockFile4, mockFile5], }; const files = extractAttachmentsFromDrop(mockDataTransfer as unknown as DataTransfer); - expect(files).toHaveLength(3); + expect(files).toHaveLength(4); expect(files).toContain(mockFile1); expect(files).toContain(mockFile2); expect(files).toContain(mockFile3); - expect(files).not.toContain(mockFile4); + expect(files).toContain(mockFile4); + expect(files).not.toContain(mockFile5); }); }); diff --git a/src/browser/utils/attachmentsHandling.ts b/src/browser/utils/attachmentsHandling.ts index ecd4fe099c..3d02065b23 100644 --- a/src/browser/utils/attachmentsHandling.ts +++ b/src/browser/utils/attachmentsHandling.ts @@ -2,7 +2,7 @@ import type { FilePart } from "@/common/orpc/types"; import { MAX_SVG_TEXT_CHARS, SVG_MEDIA_TYPE } from "@/common/constants/imageAttachments"; import { MAX_STAGED_ATTACHMENT_SIZE_BYTES } from "@/common/constants/stagedAttachments"; import { - getSupportedAttachmentMediaType, + getSupportedChatAttachmentMediaType, getSupportedStagedAttachmentMediaType, } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; @@ -27,7 +27,7 @@ export interface ProcessAttachmentOptions { } function getSupportedMediaType(file: File): string | null { - return getSupportedAttachmentMediaType({ + return getSupportedChatAttachmentMediaType({ mediaType: file.type !== "" ? file.type : null, filename: file.name, }); diff --git a/src/common/utils/attachments/supportedAttachmentMediaTypes.test.ts b/src/common/utils/attachments/supportedAttachmentMediaTypes.test.ts index 712aa19717..e16fda17cd 100644 --- a/src/common/utils/attachments/supportedAttachmentMediaTypes.test.ts +++ b/src/common/utils/attachments/supportedAttachmentMediaTypes.test.ts @@ -1,11 +1,42 @@ import { describe, expect, test } from "bun:test"; import { + getAttachmentMediaTypeFromExtension, getSupportedAttachmentMediaType, + getSupportedChatAttachmentMediaType, getSupportedStagedAttachmentMediaType, + isSupportedAttachmentMediaType, + isSupportedChatAttachmentMediaType, } from "./supportedAttachmentMediaTypes"; describe("supportedAttachmentMediaTypes", () => { + test("keeps JSON out of provider attachments while mapping its extension", () => { + expect(isSupportedAttachmentMediaType("application/json")).toBe(false); + expect(isSupportedAttachmentMediaType("application/json; charset=utf-8")).toBe(false); + expect(getAttachmentMediaTypeFromExtension("config.json")).toBe("application/json"); + }); + + test("supports JSON chat attachments by media type and extension", () => { + expect(isSupportedChatAttachmentMediaType("application/json")).toBe(true); + expect(isSupportedChatAttachmentMediaType("application/json; charset=utf-8")).toBe(true); + expect(getSupportedChatAttachmentMediaType({ mediaType: "", filename: "package.json" })).toBe( + "application/json" + ); + }); + + test("keeps .json extension fallback scoped to chat attachments", () => { + expect(getSupportedAttachmentMediaType({ mediaType: "", filename: "package.json" })).toBe(null); + expect(getSupportedChatAttachmentMediaType({ mediaType: "", filename: "package.json" })).toBe( + "application/json" + ); + }); + + test("keeps arbitrary text files unsupported", () => { + expect( + getSupportedAttachmentMediaType({ mediaType: "text/plain", filename: "notes.txt" }) + ).toBeNull(); + }); + test("classifies zip files as staged attachments without making them provider attachments", () => { expect(getSupportedAttachmentMediaType({ mediaType: "", filename: "archive.zip" })).toBeNull(); expect(getSupportedStagedAttachmentMediaType({ mediaType: "", filename: "archive.zip" })).toBe( diff --git a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts index 5ab457f9dd..987f1729b2 100644 --- a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts +++ b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts @@ -2,6 +2,7 @@ import { SVG_MEDIA_TYPE } from "@/common/constants/imageAttachments"; import { ZIP_MEDIA_TYPE, ZIP_MEDIA_TYPES } from "@/common/constants/stagedAttachments"; export const PDF_MEDIA_TYPE = "application/pdf"; +export const JSON_MEDIA_TYPE = "application/json"; export const MARKDOWN_MEDIA_TYPE = "text/markdown"; const EXTENSION_TO_MEDIA_TYPE: Record = { @@ -14,8 +15,12 @@ const EXTENSION_TO_MEDIA_TYPE: Record = { avif: "image/avif", svg: SVG_MEDIA_TYPE, pdf: PDF_MEDIA_TYPE, + json: JSON_MEDIA_TYPE, }; +const SUPPORTED_PROVIDER_DOCUMENT_MEDIA_TYPES = new Set([PDF_MEDIA_TYPE]); +const SUPPORTED_CHAT_DOCUMENT_MEDIA_TYPES = new Set([PDF_MEDIA_TYPE, JSON_MEDIA_TYPE]); + export function normalizeAttachmentMediaType(mediaType: string): string { return mediaType.toLowerCase().trim().split(";")[0]; } @@ -27,7 +32,12 @@ export function getAttachmentMediaTypeFromExtension(filename: string): string | export function isSupportedAttachmentMediaType(mediaType: string): boolean { const normalized = normalizeAttachmentMediaType(mediaType); - return normalized.startsWith("image/") || normalized === PDF_MEDIA_TYPE; + return normalized.startsWith("image/") || SUPPORTED_PROVIDER_DOCUMENT_MEDIA_TYPES.has(normalized); +} + +export function isSupportedChatAttachmentMediaType(mediaType: string): boolean { + const normalized = normalizeAttachmentMediaType(mediaType); + return normalized.startsWith("image/") || SUPPORTED_CHAT_DOCUMENT_MEDIA_TYPES.has(normalized); } export function getSupportedAttachmentMediaType(args: { @@ -49,6 +59,25 @@ export function getSupportedAttachmentMediaType(args: { return isSupportedAttachmentMediaType(normalized) ? normalized : null; } +export function getSupportedChatAttachmentMediaType(args: { + mediaType?: string | null; + filename?: string | null; +}): string | null { + const trimmedMediaType = args.mediaType?.trim(); + const rawMediaType = + trimmedMediaType != null && trimmedMediaType.length > 0 + ? trimmedMediaType + : args.filename != null + ? (getAttachmentMediaTypeFromExtension(args.filename) ?? "") + : ""; + if (rawMediaType.length === 0) { + return null; + } + + const normalized = normalizeAttachmentMediaType(rawMediaType); + return isSupportedChatAttachmentMediaType(normalized) ? normalized : null; +} + export function isSupportedStagedAttachmentMediaType(mediaType: string): boolean { const normalized = normalizeAttachmentMediaType(mediaType); return ZIP_MEDIA_TYPES.includes(normalized as (typeof ZIP_MEDIA_TYPES)[number]); diff --git a/src/node/services/tools/attach_file.test.ts b/src/node/services/tools/attach_file.test.ts index 23a0f6abec..8bc7bedb70 100644 --- a/src/node/services/tools/attach_file.test.ts +++ b/src/node/services/tools/attach_file.test.ts @@ -360,6 +360,23 @@ describe("attach_file tool", () => { }); }); + it("rejects JSON files so the model can use file_read", async () => { + using workspaceDir = new TestTempDir("attach-file-workspace"); + const tool = createTestAttachFileTool(workspaceDir.path); + const jsonPath = path.join(workspaceDir.path, "package.json"); + await fs.writeFile(jsonPath, '{"name":"demo"}\n'); + + const result = (await tool.execute!( + { path: "package.json" }, + mockToolCallOptions + )) as AttachFileToolResult; + + expect(result).toEqual({ + success: false, + error: `Unsupported attachment type: ${jsonPath}`, + }); + }); + it("rejects unmapped source files so the model can use file_read", async () => { using workspaceDir = new TestTempDir("attach-file-workspace"); const tool = createTestAttachFileTool(workspaceDir.path);