420 lines
15 KiB
Markdown
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>
|