--- phase: 25-file-system plan: 02 type: execute wave: 2 depends_on: ["25-00"] files_modified: - ui/src/api/chat.ts - ui/src/components/ChatInput.tsx - ui/src/components/ChatFileDropZone.tsx - ui/src/hooks/useChatFileUpload.ts - ui/src/components/ChatInput.test.tsx autonomous: true requirements: - FILE-05 must_haves: truths: - "User can drag-and-drop a file onto the chat input area and it uploads" - "User can paste an image from clipboard into the chat input and it uploads" - "User can click a button to select a file for upload" - "Pending file uploads show as preview chips in the input area before sending" - "Upload progress is visible while file is uploading" artifacts: - path: "ui/src/components/ChatFileDropZone.tsx" provides: "Drop zone overlay and drag state management" contains: "ChatFileDropZone" - path: "ui/src/hooks/useChatFileUpload.ts" provides: "Upload state, progress, and API calls" exports: ["useChatFileUpload"] - path: "ui/src/api/chat.ts" provides: "uploadFile method on chatApi" contains: "uploadFile" key_links: - from: "ui/src/components/ChatInput.tsx" to: "ui/src/hooks/useChatFileUpload.ts" via: "useChatFileUpload hook" pattern: "useChatFileUpload" - from: "ui/src/hooks/useChatFileUpload.ts" to: "ui/src/api/chat.ts" via: "chatApi.uploadFile" pattern: "chatApi\\.uploadFile" - from: "ui/src/components/ChatInput.tsx" to: "ui/src/components/ChatFileDropZone.tsx" via: "wrapping textarea in drop zone" pattern: "ChatFileDropZone" --- Add file upload capabilities to ChatInput: drag-and-drop, clipboard paste, and file picker button. Files upload immediately and appear as pending chips in the input area. Purpose: Enable users to attach files to chat messages (FILE-05, INPUT-02, INPUT-03). Output: ChatFileDropZone component, useChatFileUpload hook, extended chatApi, updated ChatInput. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/25-file-system/25-RESEARCH.md @.planning/phases/25-file-system/25-00-SUMMARY.md @ui/src/components/ChatInput.tsx @ui/src/api/chat.ts @ui/src/components/ChatPanel.tsx @packages/shared/src/types/chat.ts POST /api/conversations/:id/files — multipart/form-data with field "file" + optional body fields Response: { file: ChatFile, contentPath: string } GET /api/files/:fileId/content — streams file binary ```typescript export interface ChatFile { id: string; filename: string; originalFilename: string; mimeType: string; sizeBytes: number; source: "user_upload" | "agent_generated"; category: "image" | "document" | "code" | "other" | null; createdAt: string; } export interface ChatFileUploadResponse { file: ChatFile; contentPath: string; } ``` ```typescript interface ChatInputProps { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean; placeholder?: string; agents?: Agent[]; agentsLoading?: boolean; } ``` Task 1: Add chatApi.uploadFile and create useChatFileUpload hook ui/src/api/chat.ts, ui/src/hooks/useChatFileUpload.ts ui/src/api/chat.ts, ui/src/hooks/useStreamingChat.ts, packages/shared/src/types/chat.ts Extend `ui/src/api/chat.ts` — add an `uploadFile` method to the `chatApi` object: ```typescript async uploadFile( conversationId: string, file: File, opts?: { source?: string; projectId?: string }, onProgress?: (percent: number) => void, ): Promise { const formData = new FormData(); formData.append("file", file); if (opts?.source) formData.append("source", opts.source); if (opts?.projectId) formData.append("projectId", opts.projectId); // Use XMLHttpRequest for progress tracking return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", `/api/conversations/${conversationId}/files`); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(`Upload failed: ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error("Upload failed")); xhr.send(formData); }); }, ``` Also add `ChatFileUploadResponse` to the import from `@paperclipai/shared` at the top of the file. Create `ui/src/hooks/useChatFileUpload.ts`: ```typescript import { useState, useCallback } from "react"; import { chatApi } from "../api/chat"; import type { ChatFile } from "@paperclipai/shared"; export interface PendingFile { id: string; // temp client-side id before upload completes file: File; // raw File object name: string; mimeType: string; sizeBytes: number; progress: number; // 0-100 status: "uploading" | "done" | "error"; uploadedFile?: ChatFile; // set when upload completes contentPath?: string; error?: string; } export function useChatFileUpload(conversationId: string | null) { const [pendingFiles, setPendingFiles] = useState([]); const addFile = useCallback(async (file: File) => { if (!conversationId) return; const tempId = crypto.randomUUID(); const pending: PendingFile = { id: tempId, file, name: file.name, mimeType: file.type || "application/octet-stream", sizeBytes: file.size, progress: 0, status: "uploading", }; setPendingFiles((prev) => [...prev, pending]); try { const result = await chatApi.uploadFile( conversationId, file, { source: "user_upload" }, (percent) => { setPendingFiles((prev) => prev.map((p) => (p.id === tempId ? { ...p, progress: percent } : p)) ); }, ); setPendingFiles((prev) => prev.map((p) => p.id === tempId ? { ...p, status: "done", progress: 100, uploadedFile: result.file, contentPath: result.contentPath } : p ) ); } catch (err) { setPendingFiles((prev) => prev.map((p) => p.id === tempId ? { ...p, status: "error", error: (err as Error).message } : p ) ); } }, [conversationId]); const removeFile = useCallback((tempId: string) => { setPendingFiles((prev) => prev.filter((p) => p.id !== tempId)); }, []); const clearCompleted = useCallback(() => { setPendingFiles((prev) => prev.filter((p) => p.status !== "done")); }, []); const completedFileIds = pendingFiles .filter((p) => p.status === "done" && p.uploadedFile) .map((p) => p.uploadedFile!.id); return { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds }; } ``` cd /opt/nexus && grep -q "uploadFile" ui/src/api/chat.ts && grep -q "useChatFileUpload" ui/src/hooks/useChatFileUpload.ts && grep -q "PendingFile" ui/src/hooks/useChatFileUpload.ts && echo "PASS" - grep "uploadFile" ui/src/api/chat.ts returns a match - grep "FormData" ui/src/api/chat.ts returns a match - grep "XMLHttpRequest" ui/src/api/chat.ts returns a match (for progress) - grep "useChatFileUpload" ui/src/hooks/useChatFileUpload.ts returns a match - grep "PendingFile" ui/src/hooks/useChatFileUpload.ts returns a match - grep "addFile" ui/src/hooks/useChatFileUpload.ts returns a match - grep "progress" ui/src/hooks/useChatFileUpload.ts returns a match chatApi.uploadFile sends multipart form data with progress tracking via XHR. useChatFileUpload hook manages pending file state with upload/progress/done/error lifecycle. Task 2: Create ChatFileDropZone and integrate into ChatInput ui/src/components/ChatFileDropZone.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatInput.test.tsx ui/src/components/ChatInput.tsx, ui/src/components/ChatInput.test.tsx, ui/src/hooks/useChatFileUpload.ts, ui/src/lib/utils.ts Create `ui/src/components/ChatFileDropZone.tsx`: A wrapper component that handles drag-and-drop state with visual feedback: ```typescript interface ChatFileDropZoneProps { onFilesDropped: (files: File[]) => void; disabled?: boolean; children: React.ReactNode; } ``` Implementation: - Track isDragOver state via onDragEnter/onDragLeave/onDragOver/onDrop - Prevent default on all drag events - On drop: extract files from e.dataTransfer.files, call onFilesDropped - When isDragOver: show a semi-transparent overlay with dashed border and "Drop files here" text, using Tailwind classes that work across all themes (use bg-primary/10, border-primary) - Use cn() from lib/utils for conditional classes Update `ui/src/components/ChatInput.tsx`: 1. Add new props to ChatInputProps: ```typescript onFilesPicked?: (files: File[]) => void; pendingFiles?: PendingFile[]; onRemoveFile?: (id: string) => void; ``` 2. Import Paperclip icon from lucide-react (for file attach button). 3. Wrap the form content in ChatFileDropZone: ```tsx files.forEach(f => onFilesPicked?.([f]))} disabled={disabled}> {/* existing form */} ``` 4. Handle paste events on the textarea: ```typescript 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) } ``` Add `onPaste={handlePaste}` to the textarea. 5. Add a file attach button (Paperclip icon) to the left of the send button: ```tsx ``` 6. Show pending file chips above the textarea when pendingFiles has items: ```tsx {pendingFiles && pendingFiles.length > 0 && (
{pendingFiles.map((pf) => (
{pf.status === "uploading" && ( )} {pf.name} {pf.status === "uploading" && ( {pf.progress}% )}
))}
)} ``` 7. Update the onSend signature: when files are attached, the parent (ChatPanel) needs to know. Keep onSend as `(content: string) => void` but the parent will read completedFileIds from the hook. No signature change needed. Update `ui/src/components/ChatInput.test.tsx`: - Add test: "renders file attach button" - Add test: "calls onFilesPicked when file input changes" - Add test: "shows pending file chips"
cd /opt/nexus && grep -q "ChatFileDropZone" ui/src/components/ChatFileDropZone.tsx && grep -q "onFilesPicked" ui/src/components/ChatInput.tsx && grep -q "handlePaste" ui/src/components/ChatInput.tsx && grep -q "Paperclip" ui/src/components/ChatInput.tsx && echo "PASS" - grep "ChatFileDropZone" ui/src/components/ChatFileDropZone.tsx returns a match - grep "onDragOver" ui/src/components/ChatFileDropZone.tsx returns a match - grep "onDrop" ui/src/components/ChatFileDropZone.tsx returns a match - grep "onFilesPicked" ui/src/components/ChatInput.tsx returns a match - grep "handlePaste" ui/src/components/ChatInput.tsx returns a match - grep "clipboardData" ui/src/components/ChatInput.tsx returns a match - grep "Paperclip" ui/src/components/ChatInput.tsx returns a match - grep "pendingFiles" ui/src/components/ChatInput.tsx returns a match - grep "type=\"file\"" ui/src/components/ChatInput.tsx returns a match ChatInput supports file upload via drag-and-drop (ChatFileDropZone), clipboard paste (onPaste handler), and file picker button (Paperclip icon with hidden input). Pending files appear as chips above the textarea with progress indicators.
- ChatFileDropZone renders overlay on drag-over - ChatInput shows Paperclip button that opens file picker - Pasting an image triggers onFilesPicked - Pending file chips show name, progress, and remove button - chatApi.uploadFile sends FormData with progress callback - useChatFileUpload manages upload lifecycle All three file input methods work: drag-and-drop shows drop zone overlay and triggers upload, clipboard paste of images triggers upload, Paperclip button opens native file picker. Pending uploads show progress chips. FILE-05 UI is complete. After completion, create `.planning/phases/25-file-system/25-02-SUMMARY.md`