From 0592af9e4b3e7894cebea7871314e6f13f52cfeb Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 23:11:55 +0000 Subject: [PATCH] feat(25-02): create ChatFileDropZone and integrate into ChatInput - Create ChatFileDropZone component with drag-and-drop state and overlay - Add onFilesPicked/pendingFiles/onRemoveFile props to ChatInput - Wrap form in ChatFileDropZone for drag-and-drop support - Add handlePaste for clipboard image paste (clipboardData.files) - Add Paperclip icon button with hidden file input for file picker - Show pending file chips above textarea with progress and remove button - Add tests: renders file attach button, calls onFilesPicked, shows pending chips --- ui/src/components/ChatFileDropZone.tsx | 67 +++++++++++ ui/src/components/ChatInput.test.tsx | 53 +++++++++ ui/src/components/ChatInput.tsx | 150 ++++++++++++++++++------- 3 files changed, 228 insertions(+), 42 deletions(-) create mode 100644 ui/src/components/ChatFileDropZone.tsx diff --git a/ui/src/components/ChatFileDropZone.tsx b/ui/src/components/ChatFileDropZone.tsx new file mode 100644 index 00000000..7ac124f6 --- /dev/null +++ b/ui/src/components/ChatFileDropZone.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { cn } from "../lib/utils"; + +interface ChatFileDropZoneProps { + onFilesDropped: (files: File[]) => void; + disabled?: boolean; + children: React.ReactNode; +} + +export function ChatFileDropZone({ onFilesDropped, disabled = false, children }: ChatFileDropZoneProps) { + const [isDragOver, setIsDragOver] = useState(false); + + function handleDragEnter(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) setIsDragOver(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + // Only set false if leaving the outer element (not entering a child) + if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) setIsDragOver(true); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (disabled) return; + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + onFilesDropped(files); + } + } + + return ( +
+ {children} + {isDragOver && ( +
+ Drop files here +
+ )} +
+ ); +} diff --git a/ui/src/components/ChatInput.test.tsx b/ui/src/components/ChatInput.test.tsx index dde7c957..b43c6cd4 100644 --- a/ui/src/components/ChatInput.test.tsx +++ b/ui/src/components/ChatInput.test.tsx @@ -163,4 +163,57 @@ describe("ChatInput", () => { expect(btn!.disabled).toBe(true); }); }); + + describe("file upload (FILE-05)", () => { + it("renders file attach button", () => { + const onSend = vi.fn(); + renderChatInput({ onSend }); + const fileInput = container.querySelector("input[type='file']"); + expect(fileInput).not.toBeNull(); + }); + + it("calls onFilesPicked when file input changes", () => { + const onSend = vi.fn(); + const onFilesPicked = vi.fn(); + root = createRoot(container); + act(() => { + root!.render(); + }); + const fileInput = container.querySelector("input[type='file']") as HTMLInputElement; + expect(fileInput).not.toBeNull(); + + const mockFile = new File(["content"], "test.txt", { type: "text/plain" }); + act(() => { + Object.defineProperty(fileInput, "files", { + value: [mockFile], + writable: false, + }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFilesPicked).toHaveBeenCalledWith([mockFile]); + }); + + it("shows pending file chips", () => { + const onSend = vi.fn(); + const pendingFiles = [ + { + id: "temp-1", + file: new File([], "image.png"), + name: "image.png", + mimeType: "image/png", + sizeBytes: 1024, + progress: 50, + status: "uploading" as const, + }, + ]; + root = createRoot(container); + act(() => { + root!.render(); + }); + const chip = container.querySelector(".bg-muted"); + expect(chip).not.toBeNull(); + expect(chip!.textContent).toContain("image.png"); + }); + }); }); diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index be2532e5..c44ec9ab 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { Send, Loader2 } from "lucide-react"; +import { Send, Loader2, Paperclip, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover"; import { ChatMentionPopover } from "./ChatMentionPopover"; +import { ChatFileDropZone } from "./ChatFileDropZone"; import { cn } from "../lib/utils"; import type { Agent } from "@paperclipai/shared"; +import type { PendingFile } from "../hooks/useChatFileUpload"; interface ChatInputProps { onSend: (content: string) => void; @@ -14,6 +16,10 @@ interface ChatInputProps { // Popover support agents?: Agent[]; agentsLoading?: boolean; + // File upload support + onFilesPicked?: (files: File[]) => void; + pendingFiles?: PendingFile[]; + onRemoveFile?: (id: string) => void; } export function ChatInput({ @@ -23,6 +29,9 @@ export function ChatInput({ placeholder = "Message your agent...", agents = [], agentsLoading = false, + onFilesPicked, + pendingFiles, + onRemoveFile, }: ChatInputProps) { const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -115,6 +124,15 @@ export function ChatInput({ // Shift+Enter falls through to default behavior (inserts newline) } + function handlePaste(e: React.ClipboardEvent) { + const files = Array.from(e.clipboardData.files); + if (files.length > 0) { + e.preventDefault(); + onFilesPicked?.(files); + } + // If no files, allow default paste behavior (text) + } + const isEmpty = value.trim().length === 0; const textarea = ( @@ -123,6 +141,7 @@ export function ChatInput({ value={value} onChange={handleChange} onKeyDown={handleKeyDown} + onPaste={handlePaste} placeholder={placeholder} rows={1} disabled={disabled} @@ -139,48 +158,95 @@ export function ChatInput({ ); return ( -
{ - e.preventDefault(); - submit(); - }} - className="flex items-end gap-2" + files.forEach((f) => onFilesPicked?.([f]))} + disabled={disabled} > - {/* Slash command takes priority over mention popover */} -
- - - {textarea} - - -
- - - + {/* Slash command takes priority over mention popover */} +
+ {/* Pending file chips above textarea */} + {pendingFiles && pendingFiles.length > 0 && ( +
+ {pendingFiles.map((pf) => ( +
+ {pf.status === "uploading" && ( + + )} + {pf.name} + {pf.status === "uploading" && ( + {pf.progress}% + )} + +
+ ))} +
+ )} + + + + {textarea} + + +
+ + {/* File attach button */} + + + + +
); }