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

420 lines
15 KiB
Markdown

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