Plans cover FILE-01 through FILE-06: DB schema + shared types (wave 1), server file service + routes and UI file upload (wave 2, parallel), file preview components + full wiring (wave 3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 25-file-system | 02 | execute | 2 |
|
|
true |
|
|
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`