---
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"
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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
</context>

<interfaces>
<!-- From Plan 01 server endpoints -->
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

<!-- From Plan 00 shared types -->
```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;
}
```

<!-- Existing ChatInput interface -->
```typescript
interface ChatInputProps {
  onSend: (content: string) => void;
  isSubmitting?: boolean;
  disabled?: boolean;
  placeholder?: string;
  agents?: Agent[];
  agentsLoading?: boolean;
}
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Add chatApi.uploadFile and create useChatFileUpload hook</name>
  <files>
    ui/src/api/chat.ts,
    ui/src/hooks/useChatFileUpload.ts
  </files>
  <read_first>
    ui/src/api/chat.ts,
    ui/src/hooks/useStreamingChat.ts,
    packages/shared/src/types/chat.ts
  </read_first>
  <action>
    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<ChatFileUploadResponse> {
      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<PendingFile[]>([]);

      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 };
    }
    ```
  </action>
  <verify>
    <automated>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"</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    chatApi.uploadFile sends multipart form data with progress tracking via XHR.
    useChatFileUpload hook manages pending file state with upload/progress/done/error lifecycle.
  </done>
</task>

<task type="auto">
  <name>Task 2: Create ChatFileDropZone and integrate into ChatInput</name>
  <files>
    ui/src/components/ChatFileDropZone.tsx,
    ui/src/components/ChatInput.tsx,
    ui/src/components/ChatInput.test.tsx
  </files>
  <read_first>
    ui/src/components/ChatInput.tsx,
    ui/src/components/ChatInput.test.tsx,
    ui/src/hooks/useChatFileUpload.ts,
    ui/src/lib/utils.ts
  </read_first>
  <action>
    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
       <ChatFileDropZone onFilesDropped={(files) => files.forEach(f => onFilesPicked?.([f]))} disabled={disabled}>
         {/* existing form */}
       </ChatFileDropZone>
       ```

    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
       <label className="shrink-0 cursor-pointer">
         <input
           type="file"
           multiple
           className="hidden"
           onChange={(e) => {
             const files = Array.from(e.target.files ?? []);
             if (files.length > 0) onFilesPicked?.(files);
             e.target.value = ""; // reset for re-selection
           }}
           disabled={disabled}
         />
         <Button type="button" variant="ghost" size="icon" asChild disabled={disabled}>
           <span><Paperclip className="h-4 w-4" /></span>
         </Button>
       </label>
       ```

    6. Show pending file chips above the textarea when pendingFiles has items:
       ```tsx
       {pendingFiles && pendingFiles.length > 0 && (
         <div className="flex flex-wrap gap-1 px-1 pb-1">
           {pendingFiles.map((pf) => (
             <div key={pf.id} className="flex items-center gap-1 rounded bg-muted px-2 py-1 text-xs">
               {pf.status === "uploading" && (
                 <Loader2 className="h-3 w-3 animate-spin" />
               )}
               <span className="max-w-[120px] truncate">{pf.name}</span>
               {pf.status === "uploading" && (
                 <span className="text-muted-foreground">{pf.progress}%</span>
               )}
               <button
                 type="button"
                 className="ml-1 text-muted-foreground hover:text-foreground"
                 onClick={() => onRemoveFile?.(pf.id)}
               >
                 <X className="h-3 w-3" />
               </button>
             </div>
           ))}
         </div>
       )}
       ```

    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"
  </action>
  <verify>
    <automated>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"</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    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.
  </done>
</task>

</tasks>

<verification>
- 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
</verification>

<success_criteria>
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.
</success_criteria>

<output>
After completion, create `.planning/phases/25-file-system/25-02-SUMMARY.md`
</output>
