docs(25-file-system): create gap closure plans 04-08
This commit is contained in:
parent
939ebd6dc3
commit
4c5a7fa691
6 changed files with 1356 additions and 3 deletions
|
|
@ -14,7 +14,7 @@
|
|||
- [x] **Phase 22: Agent Streaming** — Real-time streaming via SSE/WebSocket, agent selector, agent identity on messages, stop/edit/regenerate, slash commands and @mentions (completed 2026-04-01)
|
||||
- [x] **Phase 23: Brainstormer Flow** — Brainstormer agent persona, structured questioning flow, spec generation, PM handoff, task creation from chat, agent status updates in chat (completed 2026-04-01)
|
||||
- [x] **Phase 24: Search, History & Branching** — Full-text search across all conversations, export, conversation branching, message bookmarks (completed 2026-04-01)
|
||||
- [x] **Phase 25: File System** — Local file storage with dual scoping, libSQL tracking, inline preview, download, agent-generated files, git versioning, placeholder tracking (completed 2026-04-01)
|
||||
- [ ] **Phase 25: File System** — Local file storage with dual scoping, libSQL tracking, inline preview, download, agent-generated files, git versioning, placeholder tracking (gap closure in progress)
|
||||
- [ ] **Phase 26: PWA & Performance** — Service worker, Web App Manifest, responsive mobile layout, push notifications, install prompt, performance targets
|
||||
|
||||
---
|
||||
|
|
@ -116,13 +116,18 @@ Plans:
|
|||
5. When an agent generates a placeholder asset, `PLACEHOLDERS.md` is updated in the project directory; when the placeholder is replaced, the DB records the replacement chain and the manifest reflects the change
|
||||
6. A file uploaded in a conversation linked to a project lives in `files/projects/<slug>/`; a file from an unlinked conversation lives in `files/chat/<conversation-id>/`; the user can promote a chat file to project scope
|
||||
7. Voice input is available when local AI is enabled: user can hold the record button, speak, see a transcription preview, and confirm to send
|
||||
**Plans:** 4/4 plans complete
|
||||
**Plans:** 9 plans (4 complete + 5 gap closure)
|
||||
|
||||
Plans:
|
||||
- [x] 25-00-PLAN.md — DB schema (chat_files + chat_file_references), shared types/validators, test stubs
|
||||
- [x] 25-01-PLAN.md — Server: chatFileService + chatFileRoutes (upload, download, list, references)
|
||||
- [x] 25-02-PLAN.md — UI: ChatInput file upload (drag-drop, paste, file picker), useChatFileUpload hook
|
||||
- [x] 25-03-PLAN.md — UI: ChatFilePreview/ChatFileCard components, ChatMessage/ChatPanel wiring
|
||||
- [ ] 25-04-PLAN.md — Gap: Code syntax-highlighted preview (FILE-06) + admin claims (FILE-07, FILE-13)
|
||||
- [ ] 25-05-PLAN.md — Gap: File scope promotion API + UI (FILE-12)
|
||||
- [ ] 25-06-PLAN.md — Gap: Git integration for file operations + version history (FILE-09, FILE-10)
|
||||
- [ ] 25-07-PLAN.md — Gap: Agent-generated files + placeholder tracking (FILE-08, FILE-11)
|
||||
- [ ] 25-08-PLAN.md — Gap: Voice input via Whisper (INPUT-04) + admin claims (INPUT-02, INPUT-03)
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
|
|
@ -222,5 +227,5 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans.
|
|||
| 22. Agent Streaming | v1.3 | 6/6 | Complete | 2026-04-01 |
|
||||
| 23. Brainstormer Flow | v1.3 | 4/4 | Complete | 2026-04-01 |
|
||||
| 24. Search, History & Branching | v1.3 | 4/4 | Complete | 2026-04-01 |
|
||||
| 25. File System | v1.3 | 4/4 | Complete | 2026-04-01 |
|
||||
| 25. File System | v1.3 | 4/9 | Gap closure | - |
|
||||
| 26. PWA & Performance | v1.3 | 0/? | Not started | - |
|
||||
|
|
|
|||
174
.planning/phases/25-file-system/25-04-PLAN.md
Normal file
174
.planning/phases/25-file-system/25-04-PLAN.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
phase: 25-file-system
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["25-03"]
|
||||
files_modified:
|
||||
- ui/src/components/ChatFilePreview.tsx
|
||||
- ui/src/components/ChatCodeFilePreview.tsx
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [FILE-06, FILE-07, FILE-13]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Code files attached to messages render with syntax highlighting in the chat"
|
||||
- "FILE-07 and FILE-13 are marked Complete in REQUIREMENTS.md"
|
||||
artifacts:
|
||||
- path: "ui/src/components/ChatCodeFilePreview.tsx"
|
||||
provides: "Syntax-highlighted code file preview component"
|
||||
min_lines: 40
|
||||
- path: "ui/src/components/ChatFilePreview.tsx"
|
||||
provides: "Updated preview that delegates code files to ChatCodeFilePreview"
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatFilePreview.tsx"
|
||||
to: "ui/src/components/ChatCodeFilePreview.tsx"
|
||||
via: "import and render for code category"
|
||||
pattern: "ChatCodeFilePreview"
|
||||
- from: "ui/src/components/ChatCodeFilePreview.tsx"
|
||||
to: "/api/files/:fileId/content"
|
||||
via: "fetch to load file text content"
|
||||
pattern: "fetch.*content"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add syntax-highlighted code file preview to chat messages and close administrative requirement gaps.
|
||||
|
||||
Purpose: FILE-06 requires "code files show a syntax-highlighted preview" but ChatFilePreview currently renders only a ChatFileCard for code files. This plan fetches code file content via the existing GET /api/files/:fileId/content endpoint and renders it with highlight.js (already installed and used by ChatMarkdownMessage via rehype-highlight). Also formally marks FILE-07 (download) and FILE-13 (cross-device access) as Complete since they are functionally implemented.
|
||||
|
||||
Output: ChatCodeFilePreview component, updated ChatFilePreview, updated REQUIREMENTS.md
|
||||
</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/STATE.md
|
||||
@.planning/phases/25-file-system/25-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create ChatCodeFilePreview component</name>
|
||||
<files>ui/src/components/ChatCodeFilePreview.tsx</files>
|
||||
<read_first>
|
||||
- ui/src/components/ChatFilePreview.tsx
|
||||
- ui/src/components/ChatCodeBlock.tsx
|
||||
- ui/src/components/ChatMarkdownMessage.tsx
|
||||
- ui/src/index.css (for hljs theme CSS classes)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `ui/src/components/ChatCodeFilePreview.tsx` that:
|
||||
|
||||
1. Accepts props: `{ file: ChatFile; contentPath: string }` (same as ChatFilePreview)
|
||||
2. Uses `useState` for `content: string | null`, `loading: boolean`, `error: boolean`
|
||||
3. Uses `useEffect` to fetch `contentPath` with `credentials: "include"` and read as text. Cap at 50KB (if text.length > 50000, truncate and append `\n// ... truncated`). Set loading=false after fetch.
|
||||
4. While loading, render a skeleton: `<div className="rounded-lg border border-border bg-muted animate-pulse h-[120px]" />`
|
||||
5. On error, fall back to `<ChatFileCard file={file} contentPath={contentPath} />`
|
||||
6. On success, render:
|
||||
- A `<div className="paperclip-markdown rounded-lg border border-border overflow-hidden">` wrapper (the `paperclip-markdown` class activates existing hljs theme CSS)
|
||||
- A header bar: `<div className="flex items-center justify-between bg-card border-b border-border px-3 py-1">` with:
|
||||
- Language label from file extension (use extToLang mapping function)
|
||||
- Copy button using same pattern as ChatCodeBlock (Copy/Check icons from lucide-react, navigator.clipboard.writeText)
|
||||
- A `<pre><code>` block. Use `hljs.highlight(content, { language: lang })` from `highlight.js/lib/core` to produce highlighted HTML. Render the highlighted output safely. hljs.highlight produces only `<span class="hljs-...">` tokens from source code — this is the same trust model as rehype-highlight used in ChatMarkdownMessage. If the language is not registered, fall back to `hljs.highlightAuto(content)`.
|
||||
- Max height: `max-h-[400px] overflow-auto`
|
||||
- Below the code block, render `<ChatFileCard file={file} contentPath={contentPath} />` for the download button
|
||||
|
||||
Import highlight.js: `import hljs from "highlight.js/lib/core"`. Since rehype-highlight already uses highlight.js, the package is available. Register common languages explicitly (import from highlight.js/lib/languages/...) for: typescript, javascript, python, css, json, xml, bash, sql, go, rust, java, cpp, markdown, yaml.
|
||||
|
||||
Extension-to-language map function:
|
||||
```typescript
|
||||
function extToLang(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
|
||||
css: "css", html: "xml", json: "json", sh: "bash", bash: "bash",
|
||||
yaml: "yaml", yml: "yaml", toml: "ini", md: "markdown",
|
||||
c: "c", cpp: "cpp", cs: "csharp", kt: "kotlin", swift: "swift",
|
||||
php: "php", sql: "sql", xml: "xml",
|
||||
};
|
||||
return map[ext] ?? ext;
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && npx tsc --noEmit -p ui/tsconfig.json 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `ui/src/components/ChatCodeFilePreview.tsx` exists
|
||||
- Contains `import hljs from` for highlight.js
|
||||
- Contains `fetch(contentPath` for loading file content
|
||||
- Contains `hljs.highlight` for rendering highlighted code
|
||||
- Contains `paperclip-markdown` class on wrapper div
|
||||
- Contains `ChatFileCard` import and render for download fallback
|
||||
- Contains `max-h-[400px]` for scroll containment
|
||||
- Contains `extToLang` or equivalent extension mapping function
|
||||
</acceptance_criteria>
|
||||
<done>ChatCodeFilePreview component renders fetched code content with syntax highlighting, copy button, language label, and download card</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire ChatCodeFilePreview into ChatFilePreview and update REQUIREMENTS.md</name>
|
||||
<files>ui/src/components/ChatFilePreview.tsx, .planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- ui/src/components/ChatFilePreview.tsx
|
||||
- ui/src/components/ChatCodeFilePreview.tsx
|
||||
- .planning/REQUIREMENTS.md
|
||||
</read_first>
|
||||
<action>
|
||||
1. Update `ui/src/components/ChatFilePreview.tsx`:
|
||||
- Add import: `import { ChatCodeFilePreview } from "./ChatCodeFilePreview";`
|
||||
- Add a new branch before the fallback return, after the image branch:
|
||||
```
|
||||
if (file.category === "code") {
|
||||
return <ChatCodeFilePreview file={file} contentPath={contentPath} />;
|
||||
}
|
||||
```
|
||||
- Keep the existing fallback `return <ChatFileCard ... />` for document/other categories
|
||||
|
||||
2. Update `.planning/REQUIREMENTS.md`:
|
||||
- Change FILE-07 line from `- [ ] **FILE-07**` to `- [x] **FILE-07**` (ChatFileCard implements one-click download)
|
||||
- Change FILE-13 line from `- [ ] **FILE-13**` to `- [x] **FILE-13**` (GET /api/files/:fileId/content serves files over HTTP for cross-device access)
|
||||
- In the Traceability table, change FILE-07 status from `Pending` to `Complete`
|
||||
- In the Traceability table, change FILE-13 status from `Pending` to `Complete`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "ChatCodeFilePreview" ui/src/components/ChatFilePreview.tsx && grep "FILE-07" .planning/REQUIREMENTS.md | head -3 && grep "FILE-13" .planning/REQUIREMENTS.md | head -3</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ui/src/components/ChatFilePreview.tsx` contains `import { ChatCodeFilePreview }`
|
||||
- `ui/src/components/ChatFilePreview.tsx` contains `file.category === "code"` branch routing to ChatCodeFilePreview
|
||||
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-07**`
|
||||
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-13**`
|
||||
- REQUIREMENTS.md Traceability table shows FILE-07 as Complete
|
||||
- REQUIREMENTS.md Traceability table shows FILE-13 as Complete
|
||||
</acceptance_criteria>
|
||||
<done>Code files in chat messages render with syntax highlighting; FILE-07 and FILE-13 marked Complete in REQUIREMENTS.md</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit -p ui/tsconfig.json` passes
|
||||
- `grep "ChatCodeFilePreview" ui/src/components/ChatFilePreview.tsx` shows import and usage
|
||||
- `grep "\[x\].*FILE-07" .planning/REQUIREMENTS.md` matches
|
||||
- `grep "\[x\].*FILE-13" .planning/REQUIREMENTS.md` matches
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Code file attachments in chat render with syntax-highlighted preview (not just a download card)
|
||||
- FILE-07 and FILE-13 marked Complete in REQUIREMENTS.md
|
||||
- TypeScript compiles without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/25-file-system/25-04-SUMMARY.md`
|
||||
</output>
|
||||
239
.planning/phases/25-file-system/25-05-PLAN.md
Normal file
239
.planning/phases/25-file-system/25-05-PLAN.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
---
|
||||
phase: 25-file-system
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["25-01"]
|
||||
files_modified:
|
||||
- server/src/services/chat-files.ts
|
||||
- server/src/routes/chat-files.ts
|
||||
- ui/src/components/ChatFileCard.tsx
|
||||
- ui/src/api/chat.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [FILE-12]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can promote a chat-scoped file to project scope via a single action"
|
||||
- "PATCH /files/:fileId/promote endpoint sets projectId on a chat file"
|
||||
artifacts:
|
||||
- path: "server/src/services/chat-files.ts"
|
||||
provides: "promoteToProject service method"
|
||||
- path: "server/src/routes/chat-files.ts"
|
||||
provides: "PATCH /files/:fileId/promote endpoint"
|
||||
- path: "ui/src/components/ChatFileCard.tsx"
|
||||
provides: "Promote button when file has no projectId"
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatFileCard.tsx"
|
||||
to: "ui/src/api/chat.ts"
|
||||
via: "chatApi.promoteFile call"
|
||||
pattern: "promoteFile"
|
||||
- from: "server/src/routes/chat-files.ts"
|
||||
to: "server/src/services/chat-files.ts"
|
||||
via: "fileSvc.promoteToProject"
|
||||
pattern: "promoteToProject"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add file scope promotion: a chat-scoped file can be promoted to a project scope.
|
||||
|
||||
Purpose: FILE-12 requires that chat-scoped files can be promoted to project scope. The schema already has a nullable `projectId` FK on chatFiles, but there is no API endpoint or UI to set it. This plan adds a PATCH /files/:fileId/promote endpoint and a promote button on ChatFileCard.
|
||||
|
||||
Output: Service method, API endpoint, UI promote button, updated REQUIREMENTS.md
|
||||
</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/STATE.md
|
||||
@.planning/phases/25-file-system/25-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From packages/db/src/schema/chat_files.ts:
|
||||
```typescript
|
||||
export const chatFiles = pgTable("chat_files", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
// ... other columns
|
||||
});
|
||||
```
|
||||
|
||||
From server/src/services/chat-files.ts:
|
||||
```typescript
|
||||
export function chatFileService(db: Db) {
|
||||
return {
|
||||
create(...), getById(...), listByConversation(...), listByMessage(...),
|
||||
createReference(...), listReferences(...), attachToMessage(...)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/api/chat.ts:
|
||||
```typescript
|
||||
export const chatApi = {
|
||||
uploadFile(...), attachFilesToMessage(...)
|
||||
};
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add promoteToProject service method and API endpoint</name>
|
||||
<files>server/src/services/chat-files.ts, server/src/routes/chat-files.ts</files>
|
||||
<read_first>
|
||||
- server/src/services/chat-files.ts
|
||||
- server/src/routes/chat-files.ts
|
||||
- packages/db/src/schema/chat_files.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. In `server/src/services/chat-files.ts`, add a new method `promoteToProject` to the returned object:
|
||||
```typescript
|
||||
promoteToProject(fileId: string, projectId: string) {
|
||||
return db
|
||||
.update(chatFiles)
|
||||
.set({ projectId, updatedAt: new Date() })
|
||||
.where(eq(chatFiles.id, fileId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
```
|
||||
|
||||
2. In `server/src/routes/chat-files.ts`, add a new route BEFORE the existing `PATCH /files/:fileId` route:
|
||||
```typescript
|
||||
// PATCH /files/:fileId/promote — Promote chat file to project scope
|
||||
router.patch("/files/:fileId/promote", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const fileId = req.params.fileId as string;
|
||||
const chatFile = await fileSvc.getById(fileId);
|
||||
if (!chatFile) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, chatFile.companyId);
|
||||
|
||||
const { projectId } = req.body ?? {};
|
||||
if (!projectId || typeof projectId !== "string") {
|
||||
res.status(400).json({ error: "projectId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await fileSvc.promoteToProject(fileId, projectId);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
```
|
||||
|
||||
Place this route BEFORE the `PATCH /files/:fileId` route so Express matches `/files/:fileId/promote` before the catch-all `/files/:fileId`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "promoteToProject" server/src/services/chat-files.ts server/src/routes/chat-files.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `server/src/services/chat-files.ts` contains `promoteToProject(fileId: string, projectId: string)` method
|
||||
- `server/src/routes/chat-files.ts` contains `router.patch("/files/:fileId/promote"` route
|
||||
- The promote route appears BEFORE the generic `router.patch("/files/:fileId"` route
|
||||
- Route validates `projectId` is present and is a string
|
||||
- Route calls `fileSvc.promoteToProject(fileId, projectId)`
|
||||
</acceptance_criteria>
|
||||
<done>PATCH /files/:fileId/promote endpoint exists, validates input, updates projectId on chat file</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add promote button to ChatFileCard and API client method</name>
|
||||
<files>ui/src/api/chat.ts, ui/src/components/ChatFileCard.tsx, .planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- ui/src/api/chat.ts
|
||||
- ui/src/components/ChatFileCard.tsx
|
||||
- .planning/REQUIREMENTS.md
|
||||
</read_first>
|
||||
<action>
|
||||
1. In `ui/src/api/chat.ts`, add a new method to `chatApi`:
|
||||
```typescript
|
||||
promoteFile(fileId: string, projectId: string) {
|
||||
return api.patch<ChatFile>(`/files/${fileId}/promote`, { projectId });
|
||||
},
|
||||
```
|
||||
Also add `ChatFile` to the import from `@paperclipai/shared` if not already imported.
|
||||
|
||||
2. In `ui/src/components/ChatFileCard.tsx`:
|
||||
- Add optional props: `projectId?: string | null` and `onPromoted?: (file: ChatFile) => void` to ChatFileCardProps
|
||||
- Import `FolderUp` from lucide-react (for the promote icon)
|
||||
- Import `chatApi` from `../api/chat`
|
||||
- Import `ChatFile` from `@paperclipai/shared`
|
||||
- Add state: `const [promoting, setPromoting] = useState(false)`
|
||||
- Add a promote button that appears ONLY when `file.projectId === null && projectId && onPromoted`:
|
||||
```tsx
|
||||
{file.projectId === null && projectId && onPromoted && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setPromoting(true);
|
||||
try {
|
||||
const updated = await chatApi.promoteFile(file.id, projectId);
|
||||
onPromoted(updated);
|
||||
} finally {
|
||||
setPromoting(false);
|
||||
}
|
||||
}}
|
||||
disabled={promoting}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
aria-label="Promote to project"
|
||||
title="Promote to project"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
- Place this button before the download button
|
||||
|
||||
3. Update `.planning/REQUIREMENTS.md`:
|
||||
- Change FILE-12 line from `- [ ] **FILE-12**` to `- [x] **FILE-12**`
|
||||
- In Traceability table, change FILE-12 status from `Pending` to `Complete`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "promoteFile" ui/src/api/chat.ts && grep -n "FolderUp\|onPromoted\|promoteToProject" ui/src/components/ChatFileCard.tsx && grep "FILE-12" .planning/REQUIREMENTS.md | head -3</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ui/src/api/chat.ts` contains `promoteFile(fileId: string, projectId: string)` method
|
||||
- `ui/src/components/ChatFileCard.tsx` contains `FolderUp` import from lucide-react
|
||||
- `ui/src/components/ChatFileCard.tsx` contains `onPromoted` prop
|
||||
- `ui/src/components/ChatFileCard.tsx` contains `chatApi.promoteFile` call
|
||||
- `ui/src/components/ChatFileCard.tsx` conditionally renders promote button only when `file.projectId === null && projectId && onPromoted`
|
||||
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-12**`
|
||||
</acceptance_criteria>
|
||||
<done>Chat files can be promoted to project scope via UI button; FILE-12 marked Complete</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit -p ui/tsconfig.json` passes
|
||||
- `npx tsc --noEmit -p server/tsconfig.json` passes
|
||||
- `grep "promoteToProject" server/src/services/chat-files.ts` matches
|
||||
- `grep "promoteFile" ui/src/api/chat.ts` matches
|
||||
- `grep "\[x\].*FILE-12" .planning/REQUIREMENTS.md` matches
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- PATCH /files/:fileId/promote endpoint sets projectId on a chat file
|
||||
- ChatFileCard shows a promote button for chat-scoped files when a project context is available
|
||||
- FILE-12 marked Complete in REQUIREMENTS.md
|
||||
- TypeScript compiles without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/25-file-system/25-05-SUMMARY.md`
|
||||
</output>
|
||||
293
.planning/phases/25-file-system/25-06-PLAN.md
Normal file
293
.planning/phases/25-file-system/25-06-PLAN.md
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
---
|
||||
phase: 25-file-system
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["25-01"]
|
||||
files_modified:
|
||||
- server/src/services/git-file-service.ts
|
||||
- server/src/routes/chat-files.ts
|
||||
- server/src/services/chat-files.ts
|
||||
- packages/shared/src/types/chat.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [FILE-09, FILE-10]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every file upload creates a git commit in the storage directory"
|
||||
- "User can view the git log (version history) for any file"
|
||||
artifacts:
|
||||
- path: "server/src/services/git-file-service.ts"
|
||||
provides: "Git operations: init, commit, log for file versioning"
|
||||
min_lines: 50
|
||||
- path: "server/src/routes/chat-files.ts"
|
||||
provides: "GET /files/:fileId/history endpoint"
|
||||
- path: "packages/shared/src/types/chat.ts"
|
||||
provides: "ChatFileHistoryEntry type"
|
||||
key_links:
|
||||
- from: "server/src/routes/chat-files.ts"
|
||||
to: "server/src/services/git-file-service.ts"
|
||||
via: "gitFileService.commitFile and gitFileService.getLog"
|
||||
pattern: "gitFileService"
|
||||
- from: "server/src/routes/chat-files.ts"
|
||||
to: "server/src/services/chat-files.ts"
|
||||
via: "fileSvc.getById for file lookup"
|
||||
pattern: "fileSvc.getById"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add git versioning for file operations and version history viewing.
|
||||
|
||||
Purpose: FILE-09 requires every file operation to produce a git commit; FILE-10 requires users to view git log for any file. The current StorageService stores files as object-keyed blobs with no git tracking. This plan creates a gitFileService that wraps git CLI commands (init, add, commit, log) operating on the storage directory, and hooks it into the upload flow. A new GET /files/:fileId/history endpoint returns the git log for a file's object key.
|
||||
|
||||
Output: gitFileService, updated upload route with git commit, history endpoint, ChatFileHistoryEntry type
|
||||
</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/STATE.md
|
||||
@.planning/phases/25-file-system/25-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From server/src/storage/types.ts:
|
||||
```typescript
|
||||
export interface StorageService {
|
||||
provider: StorageProviderId;
|
||||
putFile(input: PutFileInput): Promise<PutFileResult>;
|
||||
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/routes/chat-files.ts:
|
||||
```typescript
|
||||
export function chatFileRoutes(db: Db, storage: StorageService) {
|
||||
// POST /conversations/:id/files -- upload
|
||||
// GET /files/:fileId/content -- download
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create gitFileService and ChatFileHistoryEntry type</name>
|
||||
<files>server/src/services/git-file-service.ts, packages/shared/src/types/chat.ts, packages/shared/src/index.ts</files>
|
||||
<read_first>
|
||||
- server/src/storage/local-disk-provider.ts
|
||||
- server/src/home-paths.ts
|
||||
- packages/shared/src/types/chat.ts
|
||||
- packages/shared/src/index.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Add `ChatFileHistoryEntry` type to `packages/shared/src/types/chat.ts`:
|
||||
```typescript
|
||||
export interface ChatFileHistoryEntry {
|
||||
hash: string;
|
||||
date: string;
|
||||
message: string;
|
||||
author: string;
|
||||
}
|
||||
```
|
||||
Export it from `packages/shared/src/index.ts` alongside other chat types.
|
||||
|
||||
2. Create `server/src/services/git-file-service.ts`:
|
||||
|
||||
Use `node:child_process` `execFile` (NOT `exec`) to prevent shell injection. All arguments are passed as array elements, never interpolated into a shell string. The `objectKey` is always a StorageService-generated path (companyId/namespace/date/uuid-filename) so it contains no shell metacharacters, but using execFile provides defense-in-depth.
|
||||
|
||||
```typescript
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export interface GitFileService {
|
||||
ensureRepo(storageDir: string): Promise<void>;
|
||||
commitFile(storageDir: string, objectKey: string, message: string): Promise<string | null>;
|
||||
getLog(storageDir: string, objectKey: string, limit?: number): Promise<Array<{ hash: string; date: string; message: string; author: string }>>;
|
||||
}
|
||||
|
||||
export function gitFileService(): GitFileService {
|
||||
async function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return execFile("git", args, { cwd, timeout: 10000 });
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureRepo(storageDir: string) {
|
||||
const gitDir = path.join(storageDir, ".git");
|
||||
if (existsSync(gitDir)) return;
|
||||
await git(storageDir, ["init"]);
|
||||
await git(storageDir, ["config", "user.email", "nexus@local"]);
|
||||
await git(storageDir, ["config", "user.name", "Nexus File System"]);
|
||||
},
|
||||
|
||||
async commitFile(storageDir: string, objectKey: string, message: string): Promise<string | null> {
|
||||
const filePath = path.join(storageDir, objectKey);
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
await this.ensureRepo(storageDir);
|
||||
|
||||
try {
|
||||
await git(storageDir, ["add", "--", objectKey]);
|
||||
const { stdout } = await git(storageDir, ["commit", "-m", message, "--", objectKey]);
|
||||
const match = /\[[\w\s]+\s([a-f0-9]+)\]/.exec(stdout);
|
||||
return match?.[1] ?? null;
|
||||
} catch (err) {
|
||||
const errMsg = String((err as { stderr?: string }).stderr ?? (err as Error).message ?? "");
|
||||
if (errMsg.includes("nothing to commit") || errMsg.includes("no changes added")) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async getLog(storageDir: string, objectKey: string, limit = 50) {
|
||||
try {
|
||||
await this.ensureRepo(storageDir);
|
||||
const { stdout } = await git(storageDir, [
|
||||
"log",
|
||||
`--max-count=${limit}`,
|
||||
"--format=%H|%aI|%s|%an",
|
||||
"--",
|
||||
objectKey,
|
||||
]);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [hash, date, message, author] = line.split("|");
|
||||
return { hash: hash!, date: date!, message: message!, author: author! };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && npx tsc --noEmit -p server/tsconfig.json 2>&1 | head -10 && grep "ChatFileHistoryEntry" packages/shared/src/types/chat.ts packages/shared/src/index.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/src/services/git-file-service.ts` exists
|
||||
- Contains `export function gitFileService(): GitFileService`
|
||||
- Contains `ensureRepo`, `commitFile`, `getLog` methods
|
||||
- Uses `execFile` from `node:child_process` (NOT `exec`) -- promisified via `node:util`
|
||||
- Does NOT use template string interpolation for shell commands
|
||||
- `packages/shared/src/types/chat.ts` contains `export interface ChatFileHistoryEntry`
|
||||
- `packages/shared/src/index.ts` exports `ChatFileHistoryEntry`
|
||||
</acceptance_criteria>
|
||||
<done>gitFileService created with init/commit/log operations using safe execFile; ChatFileHistoryEntry type exported from shared</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire git commits into upload flow and add history endpoint</name>
|
||||
<files>server/src/routes/chat-files.ts, .planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- server/src/routes/chat-files.ts
|
||||
- server/src/services/git-file-service.ts
|
||||
- server/src/storage/local-disk-provider.ts
|
||||
- server/src/home-paths.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
</read_first>
|
||||
<action>
|
||||
1. Update `server/src/routes/chat-files.ts`:
|
||||
|
||||
a. Import gitFileService:
|
||||
```typescript
|
||||
import { gitFileService } from "../services/git-file-service.js";
|
||||
```
|
||||
Also import `path` from `node:path`.
|
||||
|
||||
b. Determine the storage directory. Read `server/src/storage/local-disk-provider.ts` to find how it resolves the base directory. Read `server/src/home-paths.ts` to understand how the root is resolved. Inside `chatFileRoutes` function body, compute the storage root path that matches where LocalDiskProvider writes files. For example:
|
||||
```typescript
|
||||
import { getHomePath } from "../home-paths.js";
|
||||
// Adapt to match local-disk-provider.ts directory construction
|
||||
const storageDir = path.join(getHomePath(), "data", "storage");
|
||||
```
|
||||
**IMPORTANT**: Read local-disk-provider.ts first and use the EXACT same path construction it uses. Do not guess.
|
||||
|
||||
Inside `chatFileRoutes`:
|
||||
```typescript
|
||||
const gitSvc = gitFileService();
|
||||
```
|
||||
|
||||
c. After the `storage.putFile()` call and DB record creation in POST /conversations/:id/files, add a git commit (fire-and-forget to not block the response):
|
||||
```typescript
|
||||
// Git-track the uploaded file (non-blocking)
|
||||
gitSvc.commitFile(storageDir, stored.objectKey, `Upload: ${file.originalname ?? "file"}`).catch(() => {});
|
||||
```
|
||||
|
||||
d. Add a new route for version history, placed near the GET /files/:fileId/content route:
|
||||
```typescript
|
||||
// GET /files/:fileId/history -- Git version history for a file
|
||||
router.get("/files/:fileId/history", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const fileId = req.params.fileId as string;
|
||||
const chatFile = await fileSvc.getById(fileId);
|
||||
if (!chatFile) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, chatFile.companyId);
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const entries = await gitSvc.getLog(storageDir, chatFile.objectKey, limit);
|
||||
res.json({ items: entries });
|
||||
});
|
||||
```
|
||||
Place this BEFORE the `/files/:fileId/content` route so Express matches `/files/:fileId/history` before any catch-all.
|
||||
|
||||
2. Update `.planning/REQUIREMENTS.md`:
|
||||
- Change FILE-09 from `- [ ] **FILE-09**` to `- [x] **FILE-09**`
|
||||
- Change FILE-10 from `- [ ] **FILE-10**` to `- [x] **FILE-10**`
|
||||
- In Traceability table, change FILE-09 and FILE-10 status from `Pending` to `Complete`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "gitFileService\|gitSvc\|commitFile\|getLog\|/history" server/src/routes/chat-files.ts && grep "FILE-09\|FILE-10" .planning/REQUIREMENTS.md | head -6</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `server/src/routes/chat-files.ts` imports `gitFileService`
|
||||
- Contains `gitSvc.commitFile(storageDir` call after `storage.putFile`
|
||||
- Contains `router.get("/files/:fileId/history"` route
|
||||
- History route calls `gitSvc.getLog` and returns `{ items: entries }`
|
||||
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-09**`
|
||||
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-10**`
|
||||
</acceptance_criteria>
|
||||
<done>File uploads create git commits; version history available via GET /files/:fileId/history; FILE-09 and FILE-10 marked Complete</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit -p server/tsconfig.json` passes
|
||||
- `grep "commitFile" server/src/routes/chat-files.ts` matches
|
||||
- `grep "/history" server/src/routes/chat-files.ts` matches
|
||||
- `grep "\[x\].*FILE-09" .planning/REQUIREMENTS.md` matches
|
||||
- `grep "\[x\].*FILE-10" .planning/REQUIREMENTS.md` matches
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- File upload creates a git commit in the storage directory
|
||||
- GET /files/:fileId/history returns git log entries for a file
|
||||
- TypeScript compiles without errors
|
||||
- FILE-09 and FILE-10 marked Complete in REQUIREMENTS.md
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/25-file-system/25-06-SUMMARY.md`
|
||||
</output>
|
||||
273
.planning/phases/25-file-system/25-07-PLAN.md
Normal file
273
.planning/phases/25-file-system/25-07-PLAN.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
---
|
||||
phase: 25-file-system
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["25-06"]
|
||||
files_modified:
|
||||
- server/src/services/chat-files.ts
|
||||
- server/src/routes/chat-files.ts
|
||||
- server/src/services/placeholder-service.ts
|
||||
- packages/shared/src/types/chat.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [FILE-08, FILE-11]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Agent-generated files are stored via the upload API with source=agent_generated and linked to task/conversation"
|
||||
- "Placeholder files are tracked in a PLACEHOLDERS.md manifest in the project directory"
|
||||
- "Replacing a placeholder updates the manifest and records the replacement chain in the DB"
|
||||
artifacts:
|
||||
- path: "server/src/services/placeholder-service.ts"
|
||||
provides: "PLACEHOLDERS.md manifest management: add, remove, replace entries"
|
||||
min_lines: 40
|
||||
- path: "server/src/services/chat-files.ts"
|
||||
provides: "markAsPlaceholder method"
|
||||
- path: "server/src/routes/chat-files.ts"
|
||||
provides: "POST /files/:fileId/replace endpoint for placeholder replacement"
|
||||
key_links:
|
||||
- from: "server/src/routes/chat-files.ts"
|
||||
to: "server/src/services/placeholder-service.ts"
|
||||
via: "placeholderService.addEntry and replaceEntry"
|
||||
pattern: "placeholderService"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add agent-generated file support and placeholder asset tracking.
|
||||
|
||||
Purpose: FILE-08 requires agent-generated files to be stored and linked to tasks/conversations. FILE-11 requires a PLACEHOLDERS.md manifest tracking placeholder assets with replacement chains. The upload API already supports source: "agent_generated" but no code path uses it, and no placeholder tracking exists.
|
||||
|
||||
Output: Placeholder service, replace endpoint, updated service methods, updated REQUIREMENTS.md
|
||||
</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/STATE.md
|
||||
@.planning/phases/25-file-system/25-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From packages/db/src/schema/chat_files.ts:
|
||||
```typescript
|
||||
export const chatFiles = pgTable("chat_files", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
source: text("source").notNull(),
|
||||
category: text("category"),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
});
|
||||
```
|
||||
|
||||
From server/src/services/chat-files.ts:
|
||||
```typescript
|
||||
export function chatFileService(db: Db) {
|
||||
return { create(...), getById(...), attachToMessage(...), promoteToProject(...), createReference(...) };
|
||||
}
|
||||
```
|
||||
|
||||
From packages/shared/src/validators/chat.ts:
|
||||
```typescript
|
||||
source: z.enum(["user_upload", "agent_generated"]).default("user_upload"),
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create placeholderService and add markAsPlaceholder method</name>
|
||||
<files>server/src/services/placeholder-service.ts, server/src/services/chat-files.ts, packages/shared/src/types/chat.ts, packages/shared/src/index.ts</files>
|
||||
<read_first>
|
||||
- server/src/services/chat-files.ts
|
||||
- packages/shared/src/types/chat.ts
|
||||
- packages/shared/src/index.ts
|
||||
- packages/db/src/schema/chat_files.ts
|
||||
- server/src/home-paths.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Add ChatPlaceholderEntry type to packages/shared/src/types/chat.ts:
|
||||
```typescript
|
||||
export interface ChatPlaceholderEntry {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
replacedByFileId?: string;
|
||||
}
|
||||
```
|
||||
Export from packages/shared/src/index.ts alongside other chat types.
|
||||
|
||||
2. Create server/src/services/placeholder-service.ts with three methods:
|
||||
|
||||
addEntry(projectDir, entry): Reads existing PLACEHOLDERS.md (or creates new), adds entry to Active Placeholders table, writes back.
|
||||
|
||||
replaceEntry(projectDir, oldFileId, newFileId): Reads PLACEHOLDERS.md, moves the entry from Active to Replaced section with replacedByFileId, writes back.
|
||||
|
||||
listEntries(projectDir): Reads and returns parsed entries.
|
||||
|
||||
The PLACEHOLDERS.md format:
|
||||
```markdown
|
||||
# Placeholder Assets
|
||||
|
||||
Auto-maintained by Nexus. Do not edit manually.
|
||||
|
||||
## Active Placeholders
|
||||
|
||||
| File | Description | File ID |
|
||||
|------|-------------|---------|
|
||||
| logo.png | Generated by agent | abc-123 |
|
||||
|
||||
## Replaced
|
||||
|
||||
| Original | Description | Replaced By |
|
||||
|----------|-------------|-------------|
|
||||
| old-logo.png | Generated by agent | def-456 |
|
||||
```
|
||||
|
||||
Use readFile/writeFile from node:fs/promises. Use existsSync to check if file exists. Use mkdir with recursive:true to ensure projectDir exists before writing.
|
||||
|
||||
The serialize function builds the markdown table strings from an array of PlaceholderEntry objects ({ fileId, filename, description, replacedByFileId? }).
|
||||
|
||||
The parse function reads markdown tables using a regex like /^\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/ to extract rows, tracking which section (Active vs Replaced) each row belongs to.
|
||||
|
||||
3. In server/src/services/chat-files.ts, add markAsPlaceholder method to the returned object:
|
||||
```typescript
|
||||
markAsPlaceholder(fileId: string) {
|
||||
return db
|
||||
.update(chatFiles)
|
||||
.set({ category: "placeholder", updatedAt: new Date() })
|
||||
.where(eq(chatFiles.id, fileId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && test -f server/src/services/placeholder-service.ts && echo "placeholder-service exists" && grep "ChatPlaceholderEntry" packages/shared/src/types/chat.ts && grep "markAsPlaceholder" server/src/services/chat-files.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File server/src/services/placeholder-service.ts exists
|
||||
- Contains addEntry, replaceEntry, listEntries exported methods
|
||||
- Generates PLACEHOLDERS.md with Active Placeholders and Replaced markdown tables
|
||||
- packages/shared/src/types/chat.ts contains export interface ChatPlaceholderEntry
|
||||
- packages/shared/src/index.ts exports ChatPlaceholderEntry
|
||||
- server/src/services/chat-files.ts contains markAsPlaceholder method
|
||||
</acceptance_criteria>
|
||||
<done>PlaceholderService manages PLACEHOLDERS.md manifest; chatFileService has markAsPlaceholder method</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add placeholder and agent-generated file routes</name>
|
||||
<files>server/src/routes/chat-files.ts, .planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- server/src/routes/chat-files.ts
|
||||
- server/src/services/placeholder-service.ts
|
||||
- server/src/services/chat-files.ts
|
||||
- server/src/services/git-file-service.ts
|
||||
- server/src/home-paths.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
</read_first>
|
||||
<action>
|
||||
1. Update server/src/routes/chat-files.ts:
|
||||
|
||||
a. Import placeholderService:
|
||||
```typescript
|
||||
import { placeholderService } from "../services/placeholder-service.js";
|
||||
```
|
||||
|
||||
b. Inside chatFileRoutes, instantiate:
|
||||
```typescript
|
||||
const phSvc = placeholderService();
|
||||
```
|
||||
|
||||
c. In the existing POST /conversations/:id/files upload route, after creating the chatFile DB record (after the git commit line from Plan 25-06), add placeholder handling:
|
||||
```typescript
|
||||
// Track placeholder if agent-generated and project-scoped
|
||||
if (parsedMeta.data.source === "agent_generated" && chatFile.projectId) {
|
||||
const projectDir = path.join(storageDir, "..", "..", "projects", chatFile.projectId);
|
||||
phSvc.addEntry(projectDir, {
|
||||
fileId: chatFile.id,
|
||||
filename: chatFile.originalFilename,
|
||||
description: "Generated by agent",
|
||||
}).catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
d. Add POST /files/:fileId/replace endpoint for replacing a placeholder with a final asset. Place it near other /files/:fileId routes:
|
||||
```typescript
|
||||
router.post("/files/:fileId/replace", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const fileId = req.params.fileId as string;
|
||||
const oldFile = await fileSvc.getById(fileId);
|
||||
if (!oldFile) { res.status(404).json({ error: "File not found" }); return; }
|
||||
assertCompanyAccess(req, oldFile.companyId);
|
||||
|
||||
const { newFileId } = req.body ?? {};
|
||||
if (!newFileId || typeof newFileId !== "string") {
|
||||
res.status(400).json({ error: "newFileId is required" }); return;
|
||||
}
|
||||
const newFile = await fileSvc.getById(newFileId);
|
||||
if (!newFile) { res.status(404).json({ error: "Replacement file not found" }); return; }
|
||||
|
||||
// Update placeholder manifest if project-scoped
|
||||
if (oldFile.projectId) {
|
||||
const projectDir = path.join(storageDir, "..", "..", "projects", oldFile.projectId);
|
||||
await phSvc.replaceEntry(projectDir, fileId, newFileId);
|
||||
}
|
||||
|
||||
// Create reference linking replacement to original context
|
||||
await fileSvc.createReference({
|
||||
fileId: newFileId,
|
||||
conversationId: oldFile.conversationId ?? "",
|
||||
messageId: oldFile.messageId ?? undefined,
|
||||
});
|
||||
|
||||
res.json({ replaced: fileId, replacedBy: newFileId });
|
||||
});
|
||||
```
|
||||
|
||||
2. Update .planning/REQUIREMENTS.md:
|
||||
- Change FILE-08 from `- [ ] **FILE-08**` to `- [x] **FILE-08**`
|
||||
- Change FILE-11 from `- [ ] **FILE-11**` to `- [x] **FILE-11**`
|
||||
- In Traceability table, change FILE-08 and FILE-11 from Pending to Complete
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "placeholderService\|phSvc\|replace\|agent_generated" server/src/routes/chat-files.ts | head -10 && grep "FILE-08\|FILE-11" .planning/REQUIREMENTS.md | head -6</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- server/src/routes/chat-files.ts imports placeholderService
|
||||
- Contains phSvc.addEntry call for agent_generated files with projectId
|
||||
- Contains router.post("/files/:fileId/replace") endpoint
|
||||
- Replace endpoint calls phSvc.replaceEntry and fileSvc.createReference
|
||||
- .planning/REQUIREMENTS.md contains `- [x] **FILE-08**`
|
||||
- .planning/REQUIREMENTS.md contains `- [x] **FILE-11**`
|
||||
</acceptance_criteria>
|
||||
<done>Agent-generated files trigger placeholder manifest update; replacement endpoint exists; FILE-08 and FILE-11 marked Complete</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- npx tsc --noEmit -p server/tsconfig.json passes
|
||||
- grep "placeholderService" server/src/routes/chat-files.ts matches
|
||||
- grep "/replace" server/src/routes/chat-files.ts matches
|
||||
- grep "\[x\].*FILE-08" .planning/REQUIREMENTS.md matches
|
||||
- grep "\[x\].*FILE-11" .planning/REQUIREMENTS.md matches
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Agent-generated files uploaded with source=agent_generated trigger PLACEHOLDERS.md update when project-scoped
|
||||
- POST /files/:fileId/replace updates manifest and creates reference chain
|
||||
- FILE-08 and FILE-11 marked Complete in REQUIREMENTS.md
|
||||
- TypeScript compiles without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/25-file-system/25-07-SUMMARY.md`
|
||||
</output>
|
||||
369
.planning/phases/25-file-system/25-08-PLAN.md
Normal file
369
.planning/phases/25-file-system/25-08-PLAN.md
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
---
|
||||
phase: 25-file-system
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["25-02"]
|
||||
files_modified:
|
||||
- ui/src/components/VoiceRecordButton.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- server/src/routes/chat-files.ts
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: [INPUT-02, INPUT-03, INPUT-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can hold a record button to capture voice audio"
|
||||
- "Recorded audio is sent to the server for transcription"
|
||||
- "Transcription preview appears in the chat input before sending"
|
||||
artifacts:
|
||||
- path: "ui/src/components/VoiceRecordButton.tsx"
|
||||
provides: "Voice recording button with MediaRecorder API, preview, and confirm flow"
|
||||
min_lines: 60
|
||||
- path: "server/src/routes/chat-files.ts"
|
||||
provides: "POST /transcribe endpoint for audio transcription"
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatInput.tsx"
|
||||
to: "ui/src/components/VoiceRecordButton.tsx"
|
||||
via: "import and render in input toolbar"
|
||||
pattern: "VoiceRecordButton"
|
||||
- from: "ui/src/components/VoiceRecordButton.tsx"
|
||||
to: "/api/transcribe"
|
||||
via: "fetch POST with audio blob"
|
||||
pattern: "fetch.*transcribe"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add voice input with transcription preview to the chat input.
|
||||
|
||||
Purpose: INPUT-04 requires voice input via Whisper when local AI is enabled: user can hold a record button, speak, see a transcription preview, and confirm to send. This plan creates a VoiceRecordButton component using the browser MediaRecorder API, a server transcription endpoint that calls a local Whisper process, and wires the transcription result into the chat input textarea.
|
||||
|
||||
Output: VoiceRecordButton component, server transcription endpoint, ChatInput integration
|
||||
</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/STATE.md
|
||||
@.planning/phases/25-file-system/25-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From ui/src/components/ChatInput.tsx:
|
||||
- Props include onSend(content: string), onFilesPicked, pendingFiles, onRemoveFile
|
||||
- Contains Paperclip button for file upload in the button row
|
||||
- Textarea with handleKeyDown and handlePaste
|
||||
|
||||
From server/src/routes/chat-files.ts:
|
||||
- chatFileRoutes(db, storage) returns Express Router
|
||||
- Uses multer for file upload, assertBoard for auth
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create VoiceRecordButton and server transcription endpoint</name>
|
||||
<files>ui/src/components/VoiceRecordButton.tsx, server/src/routes/chat-files.ts</files>
|
||||
<read_first>
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- server/src/routes/chat-files.ts
|
||||
- server/src/attachment-types.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Create ui/src/components/VoiceRecordButton.tsx:
|
||||
|
||||
```typescript
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Mic, Square, Loader2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface VoiceRecordButtonProps {
|
||||
onTranscription: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceRecordButton({ onTranscription, disabled }: VoiceRecordButtonProps) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: "audio/webm",
|
||||
});
|
||||
|
||||
chunksRef.current = [];
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
if (blob.size === 0) return;
|
||||
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", blob, "recording.webm");
|
||||
|
||||
const res = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { text: string };
|
||||
if (data.text?.trim()) {
|
||||
onTranscription(data.text.trim());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mediaRecorder.start(250); // 250ms chunks
|
||||
setRecording(true);
|
||||
} catch {
|
||||
// Microphone permission denied or unavailable
|
||||
}
|
||||
}, [onTranscription]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
mediaRecorderRef.current = null;
|
||||
}
|
||||
setRecording(false);
|
||||
}, []);
|
||||
|
||||
if (transcribing) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (recording) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={stopRecording}
|
||||
aria-label="Stop recording"
|
||||
title="Stop recording"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={startRecording}
|
||||
disabled={disabled}
|
||||
aria-label="Voice input"
|
||||
title="Voice input"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. In server/src/routes/chat-files.ts, add a transcription endpoint. This endpoint receives audio via multer, writes it to a temp file, and shells out to the `whisper` CLI (from OpenAI's whisper or whisper.cpp). If whisper is not installed, return a 503 with a helpful message.
|
||||
|
||||
Add this route inside chatFileRoutes, after the file upload routes:
|
||||
|
||||
```typescript
|
||||
// POST /transcribe -- Transcribe audio via local Whisper
|
||||
router.post("/transcribe", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(fileUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const file = (req as Request & { file?: { buffer: Buffer; mimetype: string } }).file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "Missing audio field" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
const { writeFile, unlink } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const tmpPath = path.join(tmpdir(), `nexus-audio-${Date.now()}.webm`);
|
||||
|
||||
try {
|
||||
await writeFile(tmpPath, file.buffer);
|
||||
|
||||
// Try whisper CLI (whisper.cpp or openai-whisper)
|
||||
const { promisify } = await import("node:util");
|
||||
const { execFile: execFileCb } = await import("node:child_process");
|
||||
const execFileAsync = promisify(execFileCb);
|
||||
|
||||
try {
|
||||
// Try whisper.cpp first (outputs to stdout with --output-txt --output-file -)
|
||||
const { stdout } = await execFileAsync("whisper-cpp", [
|
||||
"--model", "base.en",
|
||||
"--file", tmpPath,
|
||||
"--no-timestamps",
|
||||
"--output-txt",
|
||||
], { timeout: 30000 });
|
||||
res.json({ text: stdout.trim() });
|
||||
} catch {
|
||||
try {
|
||||
// Fallback: openai-whisper Python CLI
|
||||
const { stdout } = await execFileAsync("whisper", [
|
||||
tmpPath,
|
||||
"--model", "base.en",
|
||||
"--output_format", "txt",
|
||||
"--output_dir", tmpdir(),
|
||||
], { timeout: 60000 });
|
||||
// whisper CLI outputs to a .txt file
|
||||
const txtPath = tmpPath.replace(/\.webm$/, ".txt");
|
||||
try {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const text = await readFile(txtPath, "utf-8");
|
||||
await unlink(txtPath).catch(() => {});
|
||||
res.json({ text: text.trim() });
|
||||
} catch {
|
||||
// Parse stdout as fallback
|
||||
res.json({ text: stdout.trim() });
|
||||
}
|
||||
} catch {
|
||||
res.status(503).json({
|
||||
error: "Whisper not available. Install whisper-cpp or openai-whisper for voice input.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Note: This uses execFileAsync (promisified execFile) -- NOT exec -- to avoid shell injection. The tmpPath is system-generated and safe.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && test -f ui/src/components/VoiceRecordButton.tsx && echo "VoiceRecordButton exists" && grep -n "transcribe" server/src/routes/chat-files.ts | head -5</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File ui/src/components/VoiceRecordButton.tsx exists
|
||||
- Contains MediaRecorder API usage (navigator.mediaDevices.getUserMedia)
|
||||
- Contains fetch("/api/transcribe") call
|
||||
- Contains recording/transcribing/idle states with Mic/Square/Loader2 icons
|
||||
- server/src/routes/chat-files.ts contains router.post("/transcribe") endpoint
|
||||
- Transcription endpoint uses execFileAsync (safe, no shell) for whisper CLI
|
||||
- Returns 503 with helpful message if whisper is not installed
|
||||
</acceptance_criteria>
|
||||
<done>VoiceRecordButton captures audio and sends to /api/transcribe; server transcription endpoint processes via local Whisper</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire VoiceRecordButton into ChatInput and update REQUIREMENTS.md</name>
|
||||
<files>ui/src/components/ChatInput.tsx, .planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/components/VoiceRecordButton.tsx
|
||||
- .planning/REQUIREMENTS.md
|
||||
</read_first>
|
||||
<action>
|
||||
1. Update ui/src/components/ChatInput.tsx:
|
||||
- Import VoiceRecordButton: `import { VoiceRecordButton } from "./VoiceRecordButton";`
|
||||
- Add an optional prop `enableVoiceInput?: boolean` to ChatInput's props interface
|
||||
- Add a handler that inserts transcription text into the textarea:
|
||||
```typescript
|
||||
const handleTranscription = useCallback((text: string) => {
|
||||
// Append transcription to current input value
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const current = textarea.value;
|
||||
const newValue = current ? `${current} ${text}` : text;
|
||||
// Trigger onChange through native input event for controlled components
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype, "value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(textarea, newValue);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.focus();
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
Alternatively, if ChatInput uses a state variable for the input value, just update that state directly. Read ChatInput.tsx first to determine the correct approach.
|
||||
- Render VoiceRecordButton in the button row (next to the Paperclip button), only when `enableVoiceInput` is true:
|
||||
```tsx
|
||||
{enableVoiceInput && (
|
||||
<VoiceRecordButton
|
||||
onTranscription={handleTranscription}
|
||||
disabled={/* same disabled condition as send button if any */}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
2. Update .planning/REQUIREMENTS.md:
|
||||
- Change INPUT-04 from `- [ ] **INPUT-04**` to `- [x] **INPUT-04**`
|
||||
- In Traceability table, change INPUT-04 from Pending to Complete
|
||||
- Also change INPUT-02 and INPUT-03 from Pending to Complete if not already (they were implemented in Plan 25-02)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "VoiceRecordButton\|enableVoiceInput\|handleTranscription" ui/src/components/ChatInput.tsx | head -5 && grep "INPUT-02\|INPUT-03\|INPUT-04" .planning/REQUIREMENTS.md | head -6</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatInput.tsx imports VoiceRecordButton
|
||||
- Contains enableVoiceInput prop in the props interface
|
||||
- Contains handleTranscription callback that inserts text into textarea
|
||||
- Renders VoiceRecordButton conditionally when enableVoiceInput is true
|
||||
- .planning/REQUIREMENTS.md contains `- [x] **INPUT-02**`
|
||||
- .planning/REQUIREMENTS.md contains `- [x] **INPUT-03**`
|
||||
- .planning/REQUIREMENTS.md contains `- [x] **INPUT-04**`
|
||||
</acceptance_criteria>
|
||||
<done>Voice input button in chat input; transcription inserts into textarea; INPUT-02/03/04 marked Complete</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- npx tsc --noEmit -p ui/tsconfig.json passes
|
||||
- grep "VoiceRecordButton" ui/src/components/ChatInput.tsx matches
|
||||
- grep "\[x\].*INPUT-04" .planning/REQUIREMENTS.md matches
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- VoiceRecordButton appears in ChatInput when voice input is enabled
|
||||
- Recording captures audio, sends to /api/transcribe, and inserts result into textarea
|
||||
- Server returns transcription via local Whisper (or 503 if not installed)
|
||||
- INPUT-02, INPUT-03, INPUT-04 marked Complete in REQUIREMENTS.md
|
||||
- TypeScript compiles without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/25-file-system/25-08-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue