From f0a04e945111fe8cd210cb8eae4cbbca7d88c945 Mon Sep 17 00:00:00 2001 From: Ignacio Jimenez Rocabado Date: Wed, 27 May 2026 23:13:13 -0700 Subject: [PATCH] feat(chat): support pasted and dropped attachments --- .../chat-v2/__tests__/ChatInput.test.tsx | 159 ++++++++++++++- .../src/components/chat-v2/chat-input.tsx | 187 +++++++++++++++++- 2 files changed, 336 insertions(+), 10 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx index bb09ce391..aff6b0cba 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, createEvent } from "@testing-library/react"; import { ChatInput } from "../chat-input"; import { ChatboxHostStyleProvider, @@ -127,8 +127,65 @@ describe("ChatInput", () => { beforeEach(() => { vi.clearAllMocks(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: vi.fn(() => "blob:http://localhost/chat-input-test"), + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(), + }); }); + function createMockFile(name: string, type: string): File { + return new File(["test file contents"], name, { type }); + } + + function createClipboardPasteEvent( + target: Element, + options: { + files?: File[]; + textOnly?: boolean; + }, + ) { + const files = options.files ?? []; + const clipboardData = { + files, + items: options.textOnly + ? [ + { + kind: "string", + type: "text/plain", + getAsFile: () => null, + }, + ] + : files.map((file) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + })), + types: files.length > 0 ? ["Files"] : ["text/plain"], + }; + const event = createEvent.paste(target); + Object.defineProperty(event, "clipboardData", { + configurable: true, + value: clipboardData, + }); + return event; + } + + function createFileDragData(files: File[]) { + return { + files, + items: files.map((file) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + })), + types: ["Files"], + }; + } + describe("rendering", () => { it("renders textarea with placeholder", () => { render(); @@ -226,6 +283,106 @@ describe("ChatInput", () => { }); }); + describe("file paste and drop", () => { + it("attaches pasted clipboard images with a fallback filename", () => { + const onChangeFileAttachments = vi.fn(); + render( + , + ); + + const textarea = screen.getByPlaceholderText("Type your message..."); + const file = createMockFile("", "image/png"); + const event = createClipboardPasteEvent(textarea, { files: [file] }); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + fireEvent(textarea, event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(onChangeFileAttachments).toHaveBeenCalledTimes(1); + const attachments = onChangeFileAttachments.mock.calls[0][0]; + expect(attachments).toHaveLength(1); + expect(attachments[0].file.name).toBe("pasted-image-1.png"); + expect(attachments[0].file.type).toBe("image/png"); + expect(attachments[0].previewUrl).toBe( + "blob:http://localhost/chat-input-test", + ); + }); + + it("does not intercept plain text paste", () => { + const onChangeFileAttachments = vi.fn(); + render( + , + ); + + const textarea = screen.getByPlaceholderText("Type your message..."); + const event = createClipboardPasteEvent(textarea, { textOnly: true }); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + fireEvent(textarea, event); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(onChangeFileAttachments).not.toHaveBeenCalled(); + }); + + it("shows a drop overlay and attaches dropped files", () => { + const onChangeFileAttachments = vi.fn(); + render( + , + ); + + const composer = screen.getByTestId("chat-input-composer"); + const file = createMockFile("diagram.png", "image/png"); + const dataTransfer = createFileDragData([file]); + + fireEvent.dragEnter(composer, { dataTransfer }); + + expect( + screen.getByText("Drop image or file to attach"), + ).toBeInTheDocument(); + + fireEvent.drop(composer, { dataTransfer }); + + expect( + screen.queryByText("Drop image or file to attach"), + ).not.toBeInTheDocument(); + expect(onChangeFileAttachments).toHaveBeenCalledTimes(1); + const attachments = onChangeFileAttachments.mock.calls[0][0]; + expect(attachments[0].file).toBe(file); + expect(attachments[0].previewUrl).toBe( + "blob:http://localhost/chat-input-test", + ); + }); + + it("shows existing validation errors for invalid dropped files", () => { + const onChangeFileAttachments = vi.fn(); + render( + , + ); + + const composer = screen.getByTestId("chat-input-composer"); + const file = createMockFile("clip.mp4", "video/mp4"); + + fireEvent.drop(composer, { dataTransfer: createFileDragData([file]) }); + + expect(onChangeFileAttachments).not.toHaveBeenCalled(); + expect( + screen.getByText(/clip\.mp4: Unsupported file type/), + ).toBeInTheDocument(); + }); + }); + describe("form submission", () => { it("calls onSubmit when form is submitted", () => { const onSubmit = vi.fn((e) => e.preventDefault()); diff --git a/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx b/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx index e11c8001a..78e4ba372 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx @@ -3,9 +3,14 @@ import { useState, useCallback, useLayoutEffect, - type ChangeEvent, } from "react"; -import type { FormEvent, KeyboardEvent } from "react"; +import type { + ChangeEvent, + ClipboardEvent, + DragEvent, + FormEvent, + KeyboardEvent, +} from "react"; import { usePostHog } from "posthog-js/react"; import { cn } from "@/lib/chat-utils"; import { standardEventProps } from "@/lib/PosthogUtils"; @@ -76,6 +81,62 @@ import { type ChatboxHostStyle, } from "@/lib/chatbox-client-style"; +type AttachmentInputSource = "picker" | "paste" | "drop"; + +const FILE_TRANSFER_TYPE = "Files"; +const DROP_OVERLAY_TEXT = "Drop image or file to attach"; + +function hasFileTransfer(types: DataTransfer["types"] | readonly string[]) { + return Array.from(types).includes(FILE_TRANSFER_TYPE); +} + +function getExtensionForMediaType(mediaType: string): string { + const extensionByMediaType: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "application/pdf": "pdf", + "application/json": "json", + "text/plain": "txt", + "text/csv": "csv", + }; + + return extensionByMediaType[mediaType] ?? "bin"; +} + +function normalizeIncomingFile( + file: File, + source: AttachmentInputSource, + index: number, +): File { + if (source !== "paste" || file.name.trim().length > 0) { + return file; + } + + const isImage = file.type.startsWith("image/"); + const prefix = isImage ? "pasted-image" : "pasted-file"; + const extension = getExtensionForMediaType(file.type); + + return new File([file], `${prefix}-${index + 1}.${extension}`, { + type: file.type, + lastModified: file.lastModified, + }); +} + +function getFilesFromClipboardData(dataTransfer: DataTransfer): File[] { + const filesFromItems = Array.from(dataTransfer.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + + if (filesFromItems.length > 0) { + return filesFromItems; + } + + return Array.from(dataTransfer.files); +} + interface ChatInputProps { value: string; onChange: (value: string) => void; @@ -222,6 +283,7 @@ export function ChatInput({ string | null >(null); const [fileError, setFileError] = useState(null); + const [fileDragDepth, setFileDragDepth] = useState(0); const posthog = usePostHog(); const [plusPopoverOpen, setPlusPopoverOpen] = useState(false); const handlePlusPopoverOpenChange = (nextOpen: boolean) => { @@ -285,18 +347,25 @@ export function ChatInput({ onChangeMcpPromptResults(mcpPromptResults.filter((_, i) => i !== index)); }; + const canHandleFileTransfers = Boolean(onChangeFileAttachments); + const canAttachFiles = canHandleFileTransfers && !disabled; + const isFileDragActive = fileDragDepth > 0; + // File attachment handlers - const handleFileInputChange = useCallback( - (event: ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0 || !onChangeFileAttachments) return; + const addFileAttachments = useCallback( + (files: Iterable, source: AttachmentInputSource) => { + if (!onChangeFileAttachments) return false; + + const incomingFiles = Array.from(files).map((file, index) => + normalizeIncomingFile(file, source, index), + ); + if (incomingFiles.length === 0) return false; setFileError(null); const newAttachments: FileAttachment[] = []; const errors: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; + for (const file of incomingFiles) { const validation = validateFile(file); if (validation.valid) { @@ -316,10 +385,92 @@ export function ChatInput({ setTimeout(() => setFileError(null), 5000); } + return true; + }, + [fileAttachments, onChangeFileAttachments], + ); + + const handleFileInputChange = useCallback( + (event: ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + addFileAttachments(Array.from(files), "picker"); + // Reset input so the same file can be selected again event.target.value = ""; }, - [fileAttachments, onChangeFileAttachments], + [addFileAttachments], + ); + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + if (!canAttachFiles) return; + + const files = getFilesFromClipboardData(event.clipboardData); + if (files.length === 0) return; + + event.preventDefault(); + addFileAttachments(files, "paste"); + textareaRef.current?.focus(); + }, + [addFileAttachments, canAttachFiles], + ); + + const handleDragEnter = useCallback( + (event: DragEvent) => { + if (!canHandleFileTransfers || !hasFileTransfer(event.dataTransfer.types)) + return; + + event.preventDefault(); + event.stopPropagation(); + if (disabled) return; + + setFileDragDepth((depth) => depth + 1); + }, + [canHandleFileTransfers, disabled], + ); + + const handleDragOver = useCallback( + (event: DragEvent) => { + if (!canHandleFileTransfers || !hasFileTransfer(event.dataTransfer.types)) + return; + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = disabled ? "none" : "copy"; + }, + [canHandleFileTransfers, disabled], + ); + + const handleDragLeave = useCallback( + (event: DragEvent) => { + if (!canHandleFileTransfers || !hasFileTransfer(event.dataTransfer.types)) + return; + + event.preventDefault(); + event.stopPropagation(); + if (disabled) return; + + setFileDragDepth((depth) => Math.max(0, depth - 1)); + }, + [canHandleFileTransfers, disabled], + ); + + const handleDrop = useCallback( + (event: DragEvent) => { + if (!canHandleFileTransfers || !hasFileTransfer(event.dataTransfer.types)) + return; + + event.preventDefault(); + event.stopPropagation(); + setFileDragDepth(0); + if (disabled) return; + + addFileAttachments(Array.from(event.dataTransfer.files), "drop"); + textareaRef.current?.focus(); + }, + [addFileAttachments, canHandleFileTransfers, disabled], ); const removeFileAttachment = useCallback( @@ -493,11 +644,28 @@ export function ChatInput({ >
+ {isFileDragActive && ( +
+ {DROP_OVERLAY_TEXT} +
+ )} + setCaretIndex(e.currentTarget.selectionStart)} placeholder={placeholder}