nexus/.planning/phases/25-file-system/25-03-PLAN.md
Nexus Dev d72c065fc7 docs(25-file-system): create phase plan — 4 plans in 3 waves
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>
2026-04-04 03:55:48 +00:00

326 lines
14 KiB
Markdown

---
phase: 25-file-system
plan: 03
type: execute
wave: 3
depends_on: ["25-01", "25-02"]
files_modified:
- ui/src/components/ChatFilePreview.tsx
- ui/src/components/ChatFileCard.tsx
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/hooks/useChatMessages.ts
- server/src/services/chat.ts
autonomous: true
requirements:
- FILE-06
must_haves:
truths:
- "Images attached to messages render inline in the chat"
- "Code files show syntax-highlighted preview in chat"
- "Any attached file can be downloaded with one click"
- "Files sent by user appear as preview cards in the conversation"
- "File previews work across all three themes"
artifacts:
- path: "ui/src/components/ChatFilePreview.tsx"
provides: "Inline file preview: images, code, documents"
contains: "ChatFilePreview"
- path: "ui/src/components/ChatFileCard.tsx"
provides: "Downloadable file card with icon and metadata"
contains: "ChatFileCard"
key_links:
- from: "ui/src/components/ChatMessage.tsx"
to: "ui/src/components/ChatFilePreview.tsx"
via: "renders file previews when message has files"
pattern: "ChatFilePreview"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/hooks/useChatFileUpload.ts"
via: "useChatFileUpload wired to ChatInput"
pattern: "useChatFileUpload"
- from: "server/src/services/chat.ts"
to: "packages/db/src/schema/chat_files.ts"
via: "join files when loading messages"
pattern: "chatFiles"
---
<objective>
Create file preview components and wire the full file flow: upload in ChatInput -> display as previews in ChatMessage -> download with one click.
Purpose: Complete the user-facing file experience (FILE-06) by rendering uploaded/generated files inline in chat with appropriate previews.
Output: ChatFilePreview, ChatFileCard components; ChatMessage renders files; ChatPanel orchestrates upload-to-message flow.
</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
@.planning/phases/25-file-system/25-01-SUMMARY.md
@.planning/phases/25-file-system/25-02-SUMMARY.md
@ui/src/components/ChatMessage.tsx
@ui/src/components/ChatPanel.tsx
@ui/src/components/ChatMessageList.tsx
@ui/src/components/ChatInput.tsx
@ui/src/components/ChatMarkdownMessage.tsx
@ui/src/hooks/useChatMessages.ts
@ui/src/hooks/useChatFileUpload.ts
@ui/src/api/chat.ts
@server/src/services/chat.ts
@server/src/services/chat-files.ts
@packages/shared/src/types/chat.ts
</context>
<interfaces>
<!-- 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;
}
// ChatMessage now has:
files?: ChatFile[];
```
<!-- From Plan 01 server endpoints -->
GET /api/files/:fileId/content — streams file binary with correct MIME type
PATCH /api/files/:fileId — attach file to message: { messageId }
<!-- From Plan 02 UI hooks -->
```typescript
export interface PendingFile {
id: string;
file: File;
name: string;
mimeType: string;
sizeBytes: number;
progress: number;
status: "uploading" | "done" | "error";
uploadedFile?: ChatFile;
contentPath?: string;
}
export function useChatFileUpload(conversationId: string | null): {
pendingFiles: PendingFile[];
addFile: (file: File) => Promise<void>;
removeFile: (tempId: string) => void;
clearCompleted: () => void;
completedFileIds: string[];
}
```
<!-- Existing ChatMessage props -->
```typescript
interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system";
content: string;
messageType?: string | null;
// ... other existing props
}
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create ChatFilePreview and ChatFileCard components</name>
<files>
ui/src/components/ChatFilePreview.tsx,
ui/src/components/ChatFileCard.tsx
</files>
<read_first>
ui/src/components/ChatMarkdownMessage.tsx,
ui/src/components/ChatCodeBlock.tsx,
ui/src/components/ChatMessage.tsx,
packages/shared/src/types/chat.ts,
ui/src/lib/utils.ts
</read_first>
<action>
Create `ui/src/components/ChatFileCard.tsx`:
A compact card for any file attachment with download capability:
```typescript
interface ChatFileCardProps {
file: ChatFile;
contentPath: string;
}
```
Implementation:
- Show file icon based on category (use lucide icons: ImageIcon for image, FileCode for code, FileText for document, File for other)
- Show filename (truncated), file size (human-readable: KB, MB), and mime type
- Download button (Download icon from lucide) — opens `/api/files/${file.id}/content` in new tab or triggers download via an anchor element with `download` attribute
- Use theme-aware classes: `bg-muted rounded-lg border border-border p-3` — works across all three themes
- Compact layout: icon + info on left, download button on right
Create `ui/src/components/ChatFilePreview.tsx`:
Renders the appropriate preview based on file category:
```typescript
interface ChatFilePreviewProps {
file: ChatFile;
contentPath: string;
}
```
Implementation:
- **Images** (category === "image"): Render `<img>` tag with `src={contentPath}` (the server serves the binary). Use `max-h-[300px] rounded-lg object-contain` to constrain size. Add loading="lazy". Wrap in a clickable link to open full-size in new tab.
- **Code files** (category === "code"): Render a ChatFileCard (do NOT attempt to fetch and syntax-highlight inline — that would require an extra fetch and significant complexity). The existing syntax highlighting from Phase 21 is for markdown code blocks, not standalone files. Show filename with code icon and download button.
- **Documents** (category === "document" and mimeType starts with "application/pdf"): Render ChatFileCard with a PDF icon. Full PDF preview is complex (would need pdf.js); a card with download is sufficient for v1.
- **Other documents** (text/plain, text/markdown, text/csv): Render ChatFileCard.
- **Everything else**: Render ChatFileCard.
Below the preview, always render ChatFileCard so there is a download button even for images.
Helper function `formatFileSize(bytes: number): string` — returns "1.2 KB", "3.4 MB", etc.
All styles must use Tailwind utility classes that respect the theme system (bg-muted, text-foreground, border-border, etc.).
</action>
<verify>
<automated>cd /opt/nexus && grep -q "ChatFilePreview" ui/src/components/ChatFilePreview.tsx && grep -q "ChatFileCard" ui/src/components/ChatFileCard.tsx && grep -q "formatFileSize" ui/src/components/ChatFileCard.tsx && grep -q "Download" ui/src/components/ChatFileCard.tsx && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- grep "ChatFilePreview" ui/src/components/ChatFilePreview.tsx returns a match
- grep "ChatFileCard" ui/src/components/ChatFileCard.tsx returns a match
- grep "img" ui/src/components/ChatFilePreview.tsx returns a match (inline image rendering)
- grep "Download" ui/src/components/ChatFileCard.tsx returns a match
- grep "formatFileSize" ui/src/components/ChatFileCard.tsx returns a match
- grep "bg-muted" ui/src/components/ChatFileCard.tsx returns a match (theme-aware styling)
- grep "contentPath" ui/src/components/ChatFilePreview.tsx returns a match
</acceptance_criteria>
<done>
ChatFilePreview renders inline images for image files and ChatFileCard for all other types.
ChatFileCard shows file metadata with one-click download. All styles work across themes.
</done>
</task>
<task type="auto">
<name>Task 2: Wire files into ChatMessage, ChatPanel, and server message loading</name>
<files>
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/hooks/useChatMessages.ts,
server/src/services/chat.ts
</files>
<read_first>
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/hooks/useChatMessages.ts,
server/src/services/chat.ts,
server/src/services/chat-files.ts,
ui/src/hooks/useChatFileUpload.ts,
ui/src/api/chat.ts
</read_first>
<action>
**Server: Include files when loading messages**
Update `server/src/services/chat.ts` — modify `listMessages` to also load files for each message:
- After fetching messages, collect all message IDs
- Query `chatFiles` WHERE messageId IN (messageIds), ordered by createdAt asc
- Group files by messageId into a Map
- Attach `files` array to each message in the response
- Import `chatFiles` from `@paperclipai/db` and `inArray` from `drizzle-orm`
- IMPORTANT: Only add file data to the response — do NOT modify the database query structure. Use a second query to fetch files, then merge.
Also update `addMessage` return: after inserting a message, return it with an empty `files: []` array for consistency.
**UI: ChatMessage renders files**
Update `ui/src/components/ChatMessage.tsx`:
1. Add `files?: ChatFile[]` to ChatMessageProps (import ChatFile from @paperclipai/shared)
2. After the message content rendering (ChatMarkdownMessage), if `files && files.length > 0`, render:
```tsx
<div className="mt-2 flex flex-col gap-2">
{files.map((f) => (
<ChatFilePreview
key={f.id}
file={f}
contentPath={`/api/files/${f.id}/content`}
/>
))}
</div>
```
3. Import ChatFilePreview from "./ChatFilePreview"
**UI: ChatMessageList passes files through**
Update `ui/src/components/ChatMessageList.tsx`:
- Ensure the `files` prop from each message object is passed to `<ChatMessage files={msg.files} ... />`
- No structural changes needed if messages already spread all props
**UI: ChatPanel orchestrates upload flow**
Update `ui/src/components/ChatPanel.tsx`:
1. Import `useChatFileUpload` from hooks
2. Call `const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);`
3. Pass to ChatInput: `pendingFiles={pendingFiles}`, `onRemoveFile={removeFile}`, `onFilesPicked={(files) => files.forEach(addFile)}`
4. In the handleSend function, after sending the message and getting the messageId back, call `chatApi.attachFiles(completedFileIds, messageId)` to link uploaded files to the message. Then call `clearCompleted()`.
- Add a new method to chatApi: `attachFilesToMessage(fileIds: string[], messageId: string)` that calls `PATCH /api/files/:fileId` for each fileId with `{ messageId }`.
- Alternatively, do this in a simpler way: after message creation, call `chatApi.attachFilesToMessage` which makes parallel PATCH calls.
5. Invalidate messages query after attaching files so the message re-renders with file previews.
Add `attachFilesToMessage` to `ui/src/api/chat.ts`:
```typescript
async attachFilesToMessage(fileIds: string[], messageId: string) {
await Promise.all(
fileIds.map((fileId) =>
api.patch(`/files/${fileId}`, { messageId })
)
);
},
```
</action>
<verify>
<automated>cd /opt/nexus && grep -q "ChatFilePreview" ui/src/components/ChatMessage.tsx && grep -q "useChatFileUpload" ui/src/components/ChatPanel.tsx && grep -q "attachFilesToMessage" ui/src/api/chat.ts && grep -q "chatFiles" server/src/services/chat.ts && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- grep "ChatFilePreview" ui/src/components/ChatMessage.tsx returns a match
- grep "files" ui/src/components/ChatMessage.tsx returns a match
- grep "useChatFileUpload" ui/src/components/ChatPanel.tsx returns a match
- grep "addFile" ui/src/components/ChatPanel.tsx returns a match
- grep "pendingFiles" ui/src/components/ChatPanel.tsx returns a match
- grep "clearCompleted" ui/src/components/ChatPanel.tsx returns a match
- grep "attachFilesToMessage" ui/src/api/chat.ts returns a match
- grep "chatFiles" server/src/services/chat.ts returns a match (file loading in listMessages)
- grep "inArray" server/src/services/chat.ts returns a match (batch file query)
</acceptance_criteria>
<done>
Full file flow works: User drops/pastes/picks file -> uploads with progress -> sends message -> files attached to message -> message renders with inline image previews and download cards. Server includes files when loading messages.
</done>
</task>
</tasks>
<verification>
- Server listMessages returns messages with files array populated
- ChatMessage renders ChatFilePreview for each attached file
- Images show inline with constrained dimensions
- Non-image files show as downloadable ChatFileCard
- ChatPanel wires useChatFileUpload to ChatInput
- After sending a message with files, files are attached and visible
- File previews use theme-aware Tailwind classes
</verification>
<success_criteria>
End-to-end file flow: upload -> store -> attach to message -> render preview -> download. Images render inline. Code and documents render as cards with download buttons. All previews work across Catppuccin Mocha, Tokyo Night, and Catppuccin Latte themes. FILE-06 is complete.
</success_criteria>
<output>
After completion, create `.planning/phases/25-file-system/25-03-SUMMARY.md`
</output>