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 (
-
+ {/* 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 */}
+
+
+
+
+
);
}