Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/browser/features/ChatInput/AttachFileButton.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -53,7 +54,7 @@ export const AttachFileButton: React.FC<AttachFileButtonProps> = (props) => {
</button>
</TooltipTrigger>
<TooltipContent>
<strong>Attach file</strong> — images, SVGs, PDFs, ZIPs
<strong>Attach file</strong> — images, SVGs, PDFs, JSON, ZIPs
</TooltipContent>
</Tooltip>
{/* Hidden file input — kept outside Tooltip to avoid stray DOM children */}
Expand Down
24 changes: 20 additions & 4 deletions src/browser/utils/attachmentsHandling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/browser/utils/attachmentsHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
});
Expand Down
31 changes: 31 additions & 0 deletions src/common/utils/attachments/supportedAttachmentMediaTypes.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
31 changes: 30 additions & 1 deletion src/common/utils/attachments/supportedAttachmentMediaTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -14,8 +15,12 @@ const EXTENSION_TO_MEDIA_TYPE: Record<string, string> = {
avif: "image/avif",
svg: SVG_MEDIA_TYPE,
pdf: PDF_MEDIA_TYPE,
json: JSON_MEDIA_TYPE,
Comment thread
LeonidasZhak marked this conversation as resolved.
};

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];
}
Expand All @@ -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: {
Expand All @@ -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]);
Expand Down
17 changes: 17 additions & 0 deletions src/node/services/tools/attach_file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down