nexus/.planning/milestones/v1.3-phases/25-file-system/25-02-PLAN.md
Nexus Dev ffc7b130e4 chore: archive v1.3 phase directories to milestones/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:48 +00:00

15 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
25-file-system 02 execute 2
25-00
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
true
FILE-05
truths artifacts key_links
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
path provides contains
ui/src/components/ChatFileDropZone.tsx Drop zone overlay and drag state management ChatFileDropZone
path provides exports
ui/src/hooks/useChatFileUpload.ts Upload state, progress, and API calls
useChatFileUpload
path provides contains
ui/src/api/chat.ts uploadFile method on chatApi uploadFile
from to via pattern
ui/src/components/ChatInput.tsx ui/src/hooks/useChatFileUpload.ts useChatFileUpload hook useChatFileUpload
from to via pattern
ui/src/hooks/useChatFileUpload.ts ui/src/api/chat.ts chatApi.uploadFile chatApi.uploadFile
from to via pattern
ui/src/components/ChatInput.tsx ui/src/components/ChatFileDropZone.tsx wrapping textarea in drop zone 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

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

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;
}
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<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 };
}
```
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
   <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"
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

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

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