docs(24-search-history-branching): create phase plan

This commit is contained in:
Nexus Dev 2026-04-01 22:23:10 +00:00
parent 6e81c3c6f3
commit 009527b809
5 changed files with 1140 additions and 2 deletions

View file

@ -94,7 +94,14 @@ Plans:
2. User can bookmark any message and later filter or navigate to bookmarked messages
3. Editing a message that already has a response creates a new branch; both the original and the new branch are preserved and the user can switch between them
4. User can export any conversation as a Markdown file or as a JSON file containing all messages and metadata
**Plans:** [To be planned]
**Plans:** 4 plans
Plans:
- [ ] 24-00-PLAN.md — DB migrations (branch columns, tsvector+GIN, bookmarks table), shared types, Wave 0 test stubs
- [ ] 24-01-PLAN.md — Server: search, bookmark, branch, export service methods and Express routes
- [ ] 24-02-PLAN.md — UI: ChatSearchDialog, ChatMessageBookmark, ChatBookmarkList, ChatBranchSelector, API client, hooks
- [ ] 24-03-PLAN.md — Wiring: ChatPanel integration, CommandPalette search item, scroll-to-message, bookmark toggle, branch-on-edit
**UI hint**: yes
### Phase 25: File System
@ -207,6 +214,6 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans.
| 21. Chat Foundation | v1.3 | 7/7 | Complete | 2026-04-01 |
| 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 | 0/? | Not started | - |
| 24. Search, History & Branching | v1.3 | 0/4 | Planned | - |
| 25. File System | v1.3 | 0/? | Not started | - |
| 26. PWA & Performance | v1.3 | 0/? | Not started | - |

View file

@ -0,0 +1,298 @@
---
phase: 24-search-history-branching
plan: 00
type: execute
wave: 1
depends_on: []
files_modified:
- packages/db/src/migrations/0050_add_branch_columns.sql
- packages/db/src/migrations/0051_add_message_search_vector.sql
- packages/db/src/migrations/0052_create_chat_message_bookmarks.sql
- packages/db/src/migrations/meta/_journal.json
- packages/db/src/schema/chat_conversations.ts
- packages/db/src/schema/chat_messages.ts
- packages/db/src/schema/chat_message_bookmarks.ts
- packages/db/src/schema/index.ts
- packages/shared/src/types/chat.ts
- packages/shared/src/validators/chat.ts
- packages/shared/src/index.ts
- server/src/__tests__/chat-service.test.ts
- server/src/__tests__/chat-routes.test.ts
autonomous: true
requirements:
- CHAT-07
- CHAT-13
- CHAT-14
- HIST-04
- HIST-07
- HIST-08
- PERF-04
must_haves:
truths:
- "DB has parentConversationId and branchFromMessageId columns on chat_conversations"
- "DB has content_search tsvector generated column with GIN index on chat_messages"
- "DB has chat_message_bookmarks table with companyId + messageId columns"
- "Shared types include ChatMessageSearchResult, ChatBookmark, ChatConversationWithBranch"
- "Test stubs exist for searchMessages, toggleBookmark, branchConversation, exportConversation"
artifacts:
- path: "packages/db/src/migrations/0050_add_branch_columns.sql"
provides: "Branch columns migration"
contains: "parent_conversation_id"
- path: "packages/db/src/migrations/0051_add_message_search_vector.sql"
provides: "tsvector + GIN index migration"
contains: "content_search"
- path: "packages/db/src/migrations/0052_create_chat_message_bookmarks.sql"
provides: "Bookmarks table migration"
contains: "chat_message_bookmarks"
- path: "packages/db/src/schema/chat_message_bookmarks.ts"
provides: "Drizzle schema for bookmarks table"
exports: ["chatMessageBookmarks"]
- path: "packages/shared/src/types/chat.ts"
provides: "Search result, bookmark, and branch types"
contains: "ChatMessageSearchResult"
key_links:
- from: "packages/db/src/schema/chat_message_bookmarks.ts"
to: "packages/db/src/schema/index.ts"
via: "re-export"
pattern: "chatMessageBookmarks"
- from: "packages/shared/src/types/chat.ts"
to: "packages/shared/src/index.ts"
via: "re-export"
pattern: "ChatMessageSearchResult"
---
<objective>
Create the DB migrations, Drizzle schema updates, shared types/validators, and Wave 0 test stubs for Phase 24.
Purpose: Foundation layer — all subsequent plans depend on these schema changes and type definitions.
Output: Three migrations applied, updated Drizzle schemas, shared types, and test scaffolding.
</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/24-search-history-branching/24-RESEARCH.md
@packages/db/src/schema/chat_conversations.ts
@packages/db/src/schema/chat_messages.ts
@packages/db/src/schema/index.ts
@packages/db/src/migrations/meta/_journal.json
@packages/shared/src/types/chat.ts
@packages/shared/src/validators/chat.ts
@packages/shared/src/index.ts
@server/src/__tests__/chat-service.test.ts
@server/src/__tests__/chat-routes.test.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: DB migrations and Drizzle schema updates</name>
<files>
packages/db/src/migrations/0050_add_branch_columns.sql,
packages/db/src/migrations/0051_add_message_search_vector.sql,
packages/db/src/migrations/0052_create_chat_message_bookmarks.sql,
packages/db/src/migrations/meta/_journal.json,
packages/db/src/schema/chat_conversations.ts,
packages/db/src/schema/chat_messages.ts,
packages/db/src/schema/chat_message_bookmarks.ts,
packages/db/src/schema/index.ts
</files>
<read_first>
packages/db/src/schema/chat_conversations.ts,
packages/db/src/schema/chat_messages.ts,
packages/db/src/schema/index.ts,
packages/db/src/migrations/meta/_journal.json,
packages/db/src/schema/companies.ts
</read_first>
<action>
**Migration 0050_add_branch_columns.sql:**
```sql
ALTER TABLE "chat_conversations"
ADD COLUMN "parent_conversation_id" uuid REFERENCES "chat_conversations"("id") ON DELETE SET NULL,
ADD COLUMN "branch_from_message_id" uuid;
CREATE INDEX "chat_conversations_parent_idx" ON "chat_conversations" ("parent_conversation_id");
```
**Migration 0051_add_message_search_vector.sql:**
```sql
ALTER TABLE "chat_messages"
ADD COLUMN "content_search" tsvector
GENERATED ALWAYS AS (to_tsvector('english', "content")) STORED;
CREATE INDEX "chat_messages_content_search_idx"
ON "chat_messages" USING GIN ("content_search");
```
**Migration 0052_create_chat_message_bookmarks.sql:**
```sql
CREATE TABLE "chat_message_bookmarks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL REFERENCES "companies"("id"),
"message_id" uuid NOT NULL REFERENCES "chat_messages"("id") ON DELETE CASCADE,
"conversation_id" uuid NOT NULL REFERENCES "chat_conversations"("id") ON DELETE CASCADE,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "chat_bookmarks_company_message_idx" ON "chat_message_bookmarks" ("company_id", "message_id");
CREATE INDEX "chat_bookmarks_company_conv_idx" ON "chat_message_bookmarks" ("company_id", "conversation_id");
```
**Update _journal.json:** Add entries for idx 50, 51, 52 following existing format (version "7", breakpoints true). Use tags: `0050_add_branch_columns`, `0051_add_message_search_vector`, `0052_create_chat_message_bookmarks`. Use timestamp `1775200000000` for 0050, `+1000` for each subsequent.
**Update chat_conversations.ts:** Add two columns after `updatedAt`:
- `parentConversationId: uuid("parent_conversation_id").references(() => chatConversations.id, { onDelete: "set null" })`
- `branchFromMessageId: uuid("branch_from_message_id")`
Add index: `parentIdx: index("chat_conversations_parent_idx").on(table.parentConversationId)`
**Update chat_messages.ts:** Do NOT add contentSearch to the Drizzle schema — it is a Postgres generated column referenced only via raw `sql` in queries. Add a comment: `// content_search tsvector column exists in Postgres (generated stored) — queried via sql\`\` only`
**Create chat_message_bookmarks.ts:** New schema file following Pattern 3 from RESEARCH.md. Use object-syntax `(table) => ({})` for index callbacks (codebase convention).
**Update schema/index.ts:** Add `export { chatMessageBookmarks } from "./chat_message_bookmarks.js";` at the end.
**Run migrations:** `pnpm --filter @paperclipai/db db:push` or the project's migration command to apply.
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/db build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "parent_conversation_id" packages/db/src/migrations/0050_add_branch_columns.sql
- grep -q "content_search" packages/db/src/migrations/0051_add_message_search_vector.sql
- grep -q "chat_message_bookmarks" packages/db/src/migrations/0052_create_chat_message_bookmarks.sql
- grep -q "parentConversationId" packages/db/src/schema/chat_conversations.ts
- grep -q "chatMessageBookmarks" packages/db/src/schema/chat_message_bookmarks.ts
- grep -q "chatMessageBookmarks" packages/db/src/schema/index.ts
- grep -q "0050" packages/db/src/migrations/meta/_journal.json
</acceptance_criteria>
<done>Three migration SQL files exist, Drizzle schemas updated with branch columns and bookmark table, schema index exports chatMessageBookmarks, db package builds cleanly.</done>
</task>
<task type="auto">
<name>Task 2: Shared types, validators, and Wave 0 test stubs</name>
<files>
packages/shared/src/types/chat.ts,
packages/shared/src/validators/chat.ts,
packages/shared/src/index.ts,
server/src/__tests__/chat-service.test.ts,
server/src/__tests__/chat-routes.test.ts
</files>
<read_first>
packages/shared/src/types/chat.ts,
packages/shared/src/validators/chat.ts,
packages/shared/src/index.ts,
server/src/__tests__/chat-service.test.ts,
server/src/__tests__/chat-routes.test.ts
</read_first>
<action>
**Add to packages/shared/src/types/chat.ts:**
```typescript
export interface ChatMessageSearchResult {
messageId: string;
conversationId: string;
conversationTitle: string | null;
content: string;
role: "user" | "assistant" | "system";
agentId: string | null;
createdAt: string;
rank: number;
}
export interface ChatMessageSearchResponse {
items: ChatMessageSearchResult[];
}
export interface ChatBookmark {
id: string;
companyId: string;
messageId: string;
conversationId: string;
createdAt: string;
}
export interface ChatBookmarkWithMessage extends ChatBookmark {
message: ChatMessage;
conversationTitle: string | null;
}
export interface ChatBookmarkListResponse {
items: ChatBookmarkWithMessage[];
}
export interface ChatBookmarkToggleResponse {
bookmarked: boolean;
}
```
Add `parentConversationId: string | null` and `branchFromMessageId: string | null` to `ChatConversation` interface. Also add them to `ChatConversationListItem`.
**Add to packages/shared/src/validators/chat.ts:**
```typescript
export const searchMessagesSchema = z.object({
q: z.string().min(2).max(200),
limit: z.coerce.number().int().min(1).max(50).optional(),
});
export const branchConversationSchema = z.object({
branchFromMessageId: z.string().uuid(),
});
export type SearchMessages = z.infer<typeof searchMessagesSchema>;
export type BranchConversation = z.infer<typeof branchConversationSchema>;
```
**Update packages/shared/src/index.ts:** Re-export the new types (`ChatMessageSearchResult`, `ChatMessageSearchResponse`, `ChatBookmark`, `ChatBookmarkWithMessage`, `ChatBookmarkListResponse`, `ChatBookmarkToggleResponse`) and validators (`searchMessagesSchema`, `branchConversationSchema`, `SearchMessages`, `BranchConversation`).
**Add Wave 0 test stubs to chat-service.test.ts:** Add four new `describe` blocks at the end of the file:
- `describe("searchMessages", () => { it.todo("returns ranked results for matching term"); it.todo("returns empty for no match"); it.todo("respects companyId scope"); })`
- `describe("toggleBookmark", () => { it.todo("creates bookmark when not exists"); it.todo("removes bookmark when exists"); })`
- `describe("branchConversation", () => { it.todo("creates child conversation with copied messages"); it.todo("throws not found for invalid message id"); })`
- `describe("exportConversation", () => { it.todo("exports as markdown with agent names"); it.todo("exports as JSON with all messages"); })`
**Add Wave 0 test stubs to chat-routes.test.ts:** Add four new `describe` blocks:
- `describe("GET /companies/:id/messages/search", () => { it.todo("returns 200 with search results"); it.todo("returns 400 for short query"); })`
- `describe("POST /conversations/:id/bookmarks", () => { it.todo("toggles bookmark on/off"); })`
- `describe("POST /conversations/:id/branch", () => { it.todo("returns 201 with branched conversation"); })`
- `describe("GET /conversations/:id/export", () => { it.todo("returns markdown file download"); it.todo("returns JSON file download"); })`
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/shared build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "ChatMessageSearchResult" packages/shared/src/types/chat.ts
- grep -q "ChatBookmark" packages/shared/src/types/chat.ts
- grep -q "parentConversationId" packages/shared/src/types/chat.ts
- grep -q "searchMessagesSchema" packages/shared/src/validators/chat.ts
- grep -q "branchConversationSchema" packages/shared/src/validators/chat.ts
- grep -q "ChatMessageSearchResult" packages/shared/src/index.ts
- grep -q "searchMessages" server/src/__tests__/chat-service.test.ts
- grep -q "toggleBookmark" server/src/__tests__/chat-service.test.ts
- grep -q "branchConversation" server/src/__tests__/chat-service.test.ts
- grep -q "exportConversation" server/src/__tests__/chat-service.test.ts
</acceptance_criteria>
<done>Shared types include search result, bookmark, and branch interfaces. Validators include searchMessagesSchema and branchConversationSchema. ChatConversation has parentConversationId + branchFromMessageId. Test stubs exist for all four service methods and four route groups.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/db build` passes
- `pnpm --filter @paperclipai/shared build` passes
- Migration SQL files contain correct DDL
- Test stubs are `it.todo()` (not `it.skip()`)
</verification>
<success_criteria>
Three migration files exist with correct SQL. Drizzle schemas updated. Shared types exported. Wave 0 test stubs in place. Both packages build cleanly.
</success_criteria>
<output>
After completion, create `.planning/phases/24-search-history-branching/24-00-SUMMARY.md`
</output>

View file

@ -0,0 +1,253 @@
---
phase: 24-search-history-branching
plan: 01
type: execute
wave: 2
depends_on: ["24-00"]
files_modified:
- server/src/services/chat.ts
- server/src/routes/chat.ts
autonomous: true
requirements:
- CHAT-07
- CHAT-13
- CHAT-14
- HIST-04
- HIST-09
- HIST-10
- HIST-11
- PERF-04
must_haves:
truths:
- "searchMessages returns ranked results using tsvector FTS in under 500ms"
- "toggleBookmark creates or removes a bookmark for a message"
- "getBookmarks returns bookmarked messages with conversation titles"
- "branchConversation creates child conversation with copied messages up to branch point"
- "exportConversation returns Markdown or JSON file content with agent names resolved"
- "listBranches returns child conversations for a parent"
artifacts:
- path: "server/src/services/chat.ts"
provides: "searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation"
contains: "searchMessages"
- path: "server/src/routes/chat.ts"
provides: "Search, bookmark, branch, export HTTP routes"
contains: "messages/search"
key_links:
- from: "server/src/routes/chat.ts"
to: "server/src/services/chat.ts"
via: "svc.searchMessages, svc.toggleBookmark, svc.branchConversation, svc.exportConversation"
pattern: "svc\\.searchMessages"
- from: "server/src/services/chat.ts"
to: "packages/db/src/schema/chat_message_bookmarks.ts"
via: "import chatMessageBookmarks"
pattern: "chatMessageBookmarks"
---
<objective>
Implement all server-side service methods and Express routes for search, bookmarks, branching, and export.
Purpose: Complete backend API so the UI plans can wire against real endpoints.
Output: Six new service methods and five new route handlers.
</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/24-search-history-branching/24-RESEARCH.md
@.planning/phases/24-search-history-branching/24-00-SUMMARY.md
@server/src/services/chat.ts
@server/src/routes/chat.ts
@packages/db/src/schema/chat_conversations.ts
@packages/db/src/schema/chat_messages.ts
@packages/db/src/schema/chat_message_bookmarks.ts
@packages/shared/src/types/chat.ts
@packages/shared/src/validators/chat.ts
<interfaces>
<!-- From packages/shared/src/types/chat.ts (after Plan 00): -->
ChatMessageSearchResult { messageId, conversationId, conversationTitle, content, role, agentId, createdAt, rank }
ChatMessageSearchResponse { items: ChatMessageSearchResult[] }
ChatBookmark { id, companyId, messageId, conversationId, createdAt }
ChatBookmarkWithMessage extends ChatBookmark { message: ChatMessage, conversationTitle }
ChatBookmarkListResponse { items: ChatBookmarkWithMessage[] }
ChatBookmarkToggleResponse { bookmarked: boolean }
<!-- From packages/shared/src/validators/chat.ts (after Plan 00): -->
searchMessagesSchema: z.object({ q: string.min(2).max(200), limit: coerce.number.optional() })
branchConversationSchema: z.object({ branchFromMessageId: string.uuid() })
<!-- From server/src/routes/chat.ts (existing pattern): -->
assertBoard(req) + assertCompanyAccess(req, companyId) guard on all company-scoped routes
chatService(db) factory pattern returning plain object
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Service methods — searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation</name>
<files>server/src/services/chat.ts</files>
<read_first>
server/src/services/chat.ts,
packages/db/src/schema/chat_messages.ts,
packages/db/src/schema/chat_conversations.ts,
packages/db/src/schema/chat_message_bookmarks.ts,
packages/db/src/schema/index.ts,
packages/shared/src/types/chat.ts
</read_first>
<action>
Add these methods to the `chatService(db)` return object. Import `sql, asc, lte` from drizzle-orm (add to existing import). Import `chatMessageBookmarks` from `@paperclipai/db`. Import `agents` from `@paperclipai/db` for export agent name resolution.
**searchMessages(companyId, query, opts: { limit?: number }):**
- Use `sql` template for `plainto_tsquery('english', ${query})` and `ts_rank`
- Join chatMessages with chatConversations on conversationId + companyId + isNull(deletedAt)
- WHERE: `sql\`"chat_messages"."content_search" @@ plainto_tsquery('english', ${query})\``
- ORDER BY: `desc(sql\`ts_rank(...)\`)`
- Limit: `Math.min(opts.limit ?? 20, 50)`
- Return `{ items }` matching ChatMessageSearchResponse shape
- If query.trim() is empty, return `{ items: [] }` early
**toggleBookmark(companyId, messageId, conversationId):**
- Check if bookmark exists: `SELECT id FROM chat_message_bookmarks WHERE company_id = ? AND message_id = ?`
- If exists: DELETE and return `{ bookmarked: false }`
- If not: INSERT and return `{ bookmarked: true }`
- Use a single transaction for atomicity
**getBookmarks(companyId, opts: { conversationId?: string }):**
- Select from chatMessageBookmarks JOIN chatMessages JOIN chatConversations
- Filter by companyId, optionally by conversationId
- Order by chatMessageBookmarks.createdAt desc
- Return `{ items }` matching ChatBookmarkListResponse shape
- Each item includes the full message object and conversationTitle
**branchConversation(parentConversationId, branchFromMessageId, companyId):**
- Follow Pattern 2 from RESEARCH.md exactly
- Get branchMsg createdAt, then get all messages up to that point ordered by asc(createdAt)
- Create new conversation with parentConversationId and branchFromMessageId set
- Copy messages into new conversation (spread rest, exclude id and conversationId)
- Return the new conversation
- Throw notFound if branchFromMessageId does not exist
**listBranches(conversationId):**
- SELECT from chatConversations WHERE parentConversationId = conversationId AND deletedAt IS NULL
- Order by createdAt desc
- Return array of conversations
**exportConversation(conversationId, format: "markdown" | "json"):**
- Get conversation metadata (title, createdAt)
- Get ALL messages ordered by asc(createdAt) — no pagination
- Join with agents table to resolve agentId -> agent name (LEFT JOIN agents ON chatMessages.agentId = agents.id)
- **Markdown format:** Build string: `# {title}\nExported: {date}\n\n---\n\n` then for each message: `**{agentName || role}** ({timestamp})\n{content}\n\n---\n\n`
- For user messages, use "You" as the speaker name
- **JSON format:** Return `JSON.stringify({ conversation: { id, title, createdAt }, messages }, null, 2)`
- Return `{ content: string, filename: string }` — filename is `{title-slug}-{date}.md` or `.json`
- Sanitize title for filename: lowercase, replace spaces with hyphens, remove special chars, truncate to 50 chars
- Add code comment: `// Note: loads all messages in memory. For very large conversations, consider streaming in future.`
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "searchMessages" server/src/services/chat.ts
- grep -q "toggleBookmark" server/src/services/chat.ts
- grep -q "getBookmarks" server/src/services/chat.ts
- grep -q "branchConversation" server/src/services/chat.ts
- grep -q "listBranches" server/src/services/chat.ts
- grep -q "exportConversation" server/src/services/chat.ts
- grep -q "plainto_tsquery" server/src/services/chat.ts
- grep -q "ts_rank" server/src/services/chat.ts
- grep -q "chatMessageBookmarks" server/src/services/chat.ts
</acceptance_criteria>
<done>Six service methods implemented: searchMessages uses tsvector FTS with ts_rank ordering, toggleBookmark does insert-or-delete, getBookmarks joins with messages and conversations, branchConversation copies messages up to branch point, listBranches queries child conversations, exportConversation resolves agent names and produces Markdown or JSON.</done>
</task>
<task type="auto">
<name>Task 2: Express routes — search, bookmarks, branch, export</name>
<files>server/src/routes/chat.ts</files>
<read_first>
server/src/routes/chat.ts,
packages/shared/src/validators/chat.ts
</read_first>
<action>
Add imports for `searchMessagesSchema` and `branchConversationSchema` from `@paperclipai/shared`.
**GET /companies/:companyId/messages/search:**
- `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
- Parse query params with `searchMessagesSchema.parse({ q: req.query.q, limit: req.query.limit })`
- Call `svc.searchMessages(companyId, parsed.q, { limit: parsed.limit })`
- Wrap parse in try/catch — on ZodError return 400 with `{ error: "Query must be at least 2 characters" }`
- Return 200 with results
**POST /conversations/:id/bookmarks:**
- `assertBoard(req)`
- Get conversationId from `req.params.id`
- Get messageId from `req.body.messageId` (validate with `z.string().uuid()`)
- Get companyId by first calling `svc.getConversation(conversationId)` to obtain `conversation.companyId`
- Call `svc.toggleBookmark(companyId, messageId, conversationId)`
- Return 200 with `{ bookmarked }` response
**GET /companies/:companyId/bookmarks:**
- `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
- Optional query param `conversationId`
- Call `svc.getBookmarks(companyId, { conversationId })`
- Return 200 with results
**POST /conversations/:id/branch:**
- `assertBoard(req)`
- Parse body with `branchConversationSchema.parse(req.body)`
- Get conversation to extract companyId: `const conv = await svc.getConversation(req.params.id!)`
- Call `svc.branchConversation(req.params.id!, parsed.branchFromMessageId, conv.companyId)`
- Return 201 with new conversation
**GET /conversations/:id/branches:**
- `assertBoard(req)`
- Call `svc.listBranches(req.params.id!)`
- Return 200 with `{ items }` array
**GET /conversations/:id/export:**
- `assertBoard(req)`
- Parse format: `const format = req.query.format === "json" ? "json" : "markdown"`
- Call `svc.exportConversation(req.params.id!, format)`
- Set headers: `Content-Disposition: attachment; filename="{filename}"`, `Content-Type: {mime}`
- `res.send(content)`
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "messages/search" server/src/routes/chat.ts
- grep -q "bookmarks" server/src/routes/chat.ts
- grep -q "branch" server/src/routes/chat.ts
- grep -q "export" server/src/routes/chat.ts
- grep -q "searchMessagesSchema" server/src/routes/chat.ts
- grep -q "branchConversationSchema" server/src/routes/chat.ts
- grep -q "Content-Disposition" server/src/routes/chat.ts
</acceptance_criteria>
<done>Six route handlers implemented: message search (GET), bookmark toggle (POST), bookmark list (GET), branch create (POST), branch list (GET), export download (GET). All routes use assertBoard guard. Search validates query length. Export sets file download headers.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/server build` passes
- Search route uses searchMessagesSchema validation
- Branch route uses branchConversationSchema validation
- Export route sets Content-Disposition header
- All routes use assertBoard(req) guard pattern
</verification>
<success_criteria>
Server builds cleanly. Six service methods and six route handlers exist. Search uses tsvector FTS. Export resolves agent names. Branch copies messages. Bookmark toggles insert/delete.
</success_criteria>
<output>
After completion, create `.planning/phases/24-search-history-branching/24-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,298 @@
---
phase: 24-search-history-branching
plan: 02
type: execute
wave: 2
depends_on: ["24-00"]
files_modified:
- ui/src/api/chat.ts
- ui/src/hooks/useChatSearch.ts
- ui/src/hooks/useChatBookmarks.ts
- ui/src/components/ChatSearchDialog.tsx
- ui/src/components/ChatMessageBookmark.tsx
- ui/src/components/ChatBookmarkList.tsx
- ui/src/components/ChatBranchSelector.tsx
autonomous: true
requirements:
- CHAT-07
- CHAT-13
- CHAT-14
- HIST-04
- HIST-12
- PERF-04
must_haves:
truths:
- "ChatSearchDialog renders search results from the FTS endpoint with conversation context"
- "ChatMessageBookmark toggles a bookmark icon on any message"
- "ChatBookmarkList displays all bookmarks with navigation to source message"
- "ChatBranchSelector shows available branches and allows switching"
- "chatApi has methods for search, bookmark, branch, and export"
artifacts:
- path: "ui/src/api/chat.ts"
provides: "API client methods for search, bookmark, branch, export"
contains: "searchMessages"
- path: "ui/src/hooks/useChatSearch.ts"
provides: "TanStack Query hook for debounced message search"
exports: ["useChatSearch"]
- path: "ui/src/hooks/useChatBookmarks.ts"
provides: "TanStack Query hooks for bookmark list and toggle mutation"
exports: ["useChatBookmarks", "useToggleBookmark"]
- path: "ui/src/components/ChatSearchDialog.tsx"
provides: "Full-text search overlay using CommandDialog"
exports: ["ChatSearchDialog"]
- path: "ui/src/components/ChatMessageBookmark.tsx"
provides: "Bookmark toggle button for messages"
exports: ["ChatMessageBookmark"]
- path: "ui/src/components/ChatBookmarkList.tsx"
provides: "Filterable list of bookmarked messages"
exports: ["ChatBookmarkList"]
- path: "ui/src/components/ChatBranchSelector.tsx"
provides: "Branch picker shown when conversation has branches"
exports: ["ChatBranchSelector"]
key_links:
- from: "ui/src/hooks/useChatSearch.ts"
to: "ui/src/api/chat.ts"
via: "chatApi.searchMessages"
pattern: "chatApi\\.searchMessages"
- from: "ui/src/components/ChatSearchDialog.tsx"
to: "ui/src/hooks/useChatSearch.ts"
via: "useChatSearch hook"
pattern: "useChatSearch"
- from: "ui/src/components/ChatMessageBookmark.tsx"
to: "ui/src/hooks/useChatBookmarks.ts"
via: "useToggleBookmark mutation"
pattern: "useToggleBookmark"
---
<objective>
Create all UI components, hooks, and API client methods for search, bookmarks, branching, and export.
Purpose: Build the UI layer independently from server routes (both depend only on Plan 00 types).
Output: API client extensions, two hooks, four components ready for wiring in Plan 03.
</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/24-search-history-branching/24-RESEARCH.md
@.planning/phases/24-search-history-branching/24-00-SUMMARY.md
@ui/src/api/chat.ts
@ui/src/components/CommandPalette.tsx
@ui/src/components/ChatMessage.tsx
@ui/src/components/ChatMessageActions.tsx
@ui/src/components/ChatConversationList.tsx
@ui/src/context/ChatPanelContext.tsx
@packages/shared/src/types/chat.ts
<interfaces>
<!-- From packages/shared/src/types/chat.ts (after Plan 00): -->
ChatMessageSearchResult { messageId, conversationId, conversationTitle, content, role, agentId, createdAt, rank }
ChatMessageSearchResponse { items: ChatMessageSearchResult[] }
ChatBookmarkWithMessage extends ChatBookmark { message: ChatMessage, conversationTitle }
ChatBookmarkListResponse { items: ChatBookmarkWithMessage[] }
ChatBookmarkToggleResponse { bookmarked: boolean }
ChatConversation now has: parentConversationId: string | null, branchFromMessageId: string | null
<!-- Existing UI patterns: -->
api.get<T>(path) / api.post<T>(path, body) / api.delete<void>(path) — from ui/src/api/client.ts
CommandDialog, CommandInput, CommandList, CommandItem, CommandEmpty — from ui/src/components/ui/command.tsx
useChatPanel() — { chatOpen, activeConversationId, setChatOpen, setActiveConversationId }
Bookmark icon available from lucide-react
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: API client methods and React Query hooks</name>
<files>
ui/src/api/chat.ts,
ui/src/hooks/useChatSearch.ts,
ui/src/hooks/useChatBookmarks.ts
</files>
<read_first>
ui/src/api/chat.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/context/ChatPanelContext.tsx,
packages/shared/src/types/chat.ts
</read_first>
<action>
**Add to ui/src/api/chat.ts (chatApi object):**
```typescript
searchMessages(companyId: string, q: string, limit?: number) {
const params = new URLSearchParams({ q });
if (limit) params.set("limit", String(limit));
return api.get<ChatMessageSearchResponse>(
`/companies/${companyId}/messages/search?${params}`,
);
},
toggleBookmark(conversationId: string, messageId: string) {
return api.post<ChatBookmarkToggleResponse>(
`/conversations/${conversationId}/bookmarks`,
{ messageId },
);
},
getBookmarks(companyId: string, conversationId?: string) {
const params = new URLSearchParams();
if (conversationId) params.set("conversationId", conversationId);
const qs = params.toString();
return api.get<ChatBookmarkListResponse>(
`/companies/${companyId}/bookmarks${qs ? `?${qs}` : ""}`,
);
},
branchConversation(conversationId: string, branchFromMessageId: string) {
return api.post<ChatConversation>(
`/conversations/${conversationId}/branch`,
{ branchFromMessageId },
);
},
listBranches(conversationId: string) {
return api.get<{ items: ChatConversation[] }>(
`/conversations/${conversationId}/branches`,
);
},
exportConversation(conversationId: string, format: "markdown" | "json") {
// Returns a download URL — use window.location.href to trigger
return `/api/conversations/${conversationId}/export?format=${format}`;
},
```
Note: `exportConversation` returns a URL string (not a fetch call) since the server sends a file download. Add import for new shared types.
**Create ui/src/hooks/useChatSearch.ts:**
- `useChatSearch(companyId: string | null, query: string)` — uses `useQuery` with key `["chat", "search", companyId, query]`
- `enabled: !!companyId && query.trim().length >= 2`
- `placeholderData: (prev) => prev` (keeps previous results while loading new)
- `staleTime: 30_000` (search results stay fresh 30s)
- Calls `chatApi.searchMessages(companyId!, query)`
**Create ui/src/hooks/useChatBookmarks.ts:**
- `useChatBookmarks(companyId: string | null, conversationId?: string)` — uses `useQuery` with key `["chat", "bookmarks", companyId, conversationId]`
- `enabled: !!companyId`
- Calls `chatApi.getBookmarks(companyId!, conversationId)`
- `useToggleBookmark()` — uses `useMutation` calling `chatApi.toggleBookmark`
- On success: invalidate `["chat", "bookmarks"]` queries
- Also invalidate `["chat", "search"]` queries (per Pitfall 6 from research)
- Return `{ data, isLoading, toggleBookmark: mutation.mutate }`
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "searchMessages" ui/src/api/chat.ts
- grep -q "toggleBookmark" ui/src/api/chat.ts
- grep -q "branchConversation" ui/src/api/chat.ts
- grep -q "exportConversation" ui/src/api/chat.ts
- grep -q "useChatSearch" ui/src/hooks/useChatSearch.ts
- grep -q "placeholderData" ui/src/hooks/useChatSearch.ts
- grep -q "useToggleBookmark" ui/src/hooks/useChatBookmarks.ts
- grep -q "invalidateQueries" ui/src/hooks/useChatBookmarks.ts
</acceptance_criteria>
<done>chatApi has six new methods (searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation). useChatSearch hook debounces FTS queries. useChatBookmarks/useToggleBookmark manage bookmark state with cache invalidation.</done>
</task>
<task type="auto">
<name>Task 2: UI components — ChatSearchDialog, ChatMessageBookmark, ChatBookmarkList, ChatBranchSelector</name>
<files>
ui/src/components/ChatSearchDialog.tsx,
ui/src/components/ChatMessageBookmark.tsx,
ui/src/components/ChatBookmarkList.tsx,
ui/src/components/ChatBranchSelector.tsx
</files>
<read_first>
ui/src/components/CommandPalette.tsx,
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatMessageActions.tsx,
ui/src/components/ChatConversationList.tsx,
ui/src/components/ui/command.tsx,
ui/src/context/ChatPanelContext.tsx
</read_first>
<action>
**ChatSearchDialog.tsx:**
- Props: `{ open: boolean; onOpenChange: (open: boolean) => void; companyId: string | null; onNavigate: (conversationId: string, messageId: string) => void }`
- Uses `CommandDialog` from `ui/src/components/ui/command.tsx` (same as CommandPalette)
- Local state: `query` string, controlled by `CommandInput`
- Uses `useChatSearch(companyId, query)` hook
- Set `shouldFilter={false}` on the `Command` component — server-side search, not client-side filtering (per research State of the Art: cmdk v1.x)
- `CommandList` renders search results: each `CommandItem` shows conversationTitle (dim, small), message content snippet (truncated to ~100 chars), role badge, relative timestamp
- `CommandEmpty` shows "No results found" when query >= 2 and no results
- Placeholder text: "Search all messages..."
- On select: call `onNavigate(result.conversationId, result.messageId)` and close dialog
- Content snippet: strip markdown, truncate to 120 chars, highlight matching terms with `<mark>` tag
- Use `Search` icon from lucide-react in the input
**ChatMessageBookmark.tsx:**
- Props: `{ messageId: string; conversationId: string; isBookmarked: boolean; onToggle: () => void }`
- Renders a ghost icon button (same sizing as ChatMessageActions buttons: `h-6 w-6` button, `h-3.5 w-3.5` icon)
- Uses `Bookmark` icon from lucide-react
- When `isBookmarked`, add `fill-current` class to icon (filled bookmark)
- `aria-label`: "Remove bookmark" / "Bookmark message" based on state
- On click: call `onToggle()`
**ChatBookmarkList.tsx:**
- Props: `{ companyId: string; onNavigate: (conversationId: string, messageId: string) => void }`
- Uses `useChatBookmarks(companyId)` hook
- Renders a scrollable list of bookmarked messages
- Each item shows: conversation title (small, muted), message content preview (truncated), timestamp
- Click navigates to the message: `onNavigate(bookmark.conversationId, bookmark.message.id)`
- Empty state: "No bookmarks yet" with `Bookmark` icon
- Loading state: skeleton placeholders (match ChatConversationList pattern)
**ChatBranchSelector.tsx:**
- Props: `{ conversationId: string; branches: ChatConversation[]; activeBranchId: string | null; onSelectBranch: (id: string) => void }`
- Only renders when `branches.length > 0`
- Shows a compact horizontal bar: "Branch: [Original] [Branch 1] [Branch 2]..."
- "Original" is the parent conversation (activeBranchId === null or matches parent)
- Each branch shows its title or "Branch {n}" fallback, creation date
- Active branch has a highlighted/selected style (bg-accent)
- Uses `GitBranch` icon from lucide-react
- Clicking a branch calls `onSelectBranch(branchId)`
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "ChatSearchDialog" ui/src/components/ChatSearchDialog.tsx
- grep -q "CommandDialog" ui/src/components/ChatSearchDialog.tsx
- grep -q "shouldFilter" ui/src/components/ChatSearchDialog.tsx
- grep -q "useChatSearch" ui/src/components/ChatSearchDialog.tsx
- grep -q "ChatMessageBookmark" ui/src/components/ChatMessageBookmark.tsx
- grep -q "fill-current" ui/src/components/ChatMessageBookmark.tsx
- grep -q "ChatBookmarkList" ui/src/components/ChatBookmarkList.tsx
- grep -q "useChatBookmarks" ui/src/components/ChatBookmarkList.tsx
- grep -q "ChatBranchSelector" ui/src/components/ChatBranchSelector.tsx
- grep -q "GitBranch" ui/src/components/ChatBranchSelector.tsx
</acceptance_criteria>
<done>Four UI components created: ChatSearchDialog uses CommandDialog with server-side FTS, ChatMessageBookmark is a toggle icon button, ChatBookmarkList renders bookmarked messages with navigation, ChatBranchSelector shows a horizontal branch picker bar. All components use existing UI primitives and lucide icons.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` passes
- ChatSearchDialog uses `shouldFilter={false}` for server-side search
- ChatMessageBookmark follows ChatMessageActions button sizing
- All components accept callback props for navigation (not internal routing)
</verification>
<success_criteria>
UI builds cleanly. All four components render standalone. API client has six new methods. Hooks manage query/mutation state. Components are ready for wiring into ChatPanel in Plan 03.
</success_criteria>
<output>
After completion, create `.planning/phases/24-search-history-branching/24-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,282 @@
---
phase: 24-search-history-branching
plan: 03
type: execute
wave: 3
depends_on: ["24-01", "24-02"]
files_modified:
- ui/src/context/ChatPanelContext.tsx
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatMessageActions.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/components/ChatConversationList.tsx
- ui/src/components/CommandPalette.tsx
autonomous: false
requirements:
- CHAT-07
- CHAT-13
- CHAT-14
- HIST-04
- HIST-07
- HIST-08
- HIST-09
- HIST-10
- HIST-11
- HIST-12
- PERF-04
must_haves:
truths:
- "Cmd+K opens CommandPalette which has a 'Search chat messages' item that opens ChatSearchDialog"
- "Clicking a search result navigates to the conversation and scrolls to the message"
- "Every message shows a bookmark icon; clicking it toggles the bookmark"
- "Editing a message that has responses creates a branch conversation"
- "Branch selector appears above messages when a conversation has branches"
- "Export button in conversation header allows downloading as Markdown or JSON"
- "Bookmarked messages are accessible from a bookmarks panel"
artifacts:
- path: "ui/src/context/ChatPanelContext.tsx"
provides: "scrollToMessageId state for cross-component message navigation"
contains: "scrollToMessageId"
- path: "ui/src/components/ChatPanel.tsx"
provides: "Integration of search, bookmarks, branching, export into the chat panel"
contains: "ChatSearchDialog"
- path: "ui/src/components/ChatMessageActions.tsx"
provides: "Bookmark button on each message"
contains: "ChatMessageBookmark"
- path: "ui/src/components/CommandPalette.tsx"
provides: "Search chat messages command item"
contains: "Search chat"
key_links:
- from: "ui/src/components/CommandPalette.tsx"
to: "ui/src/components/ChatSearchDialog.tsx"
via: "opens ChatSearchDialog from command item"
pattern: "ChatSearchDialog"
- from: "ui/src/context/ChatPanelContext.tsx"
to: "ui/src/components/ChatMessageList.tsx"
via: "scrollToMessageId triggers virtualizer scrollToIndex"
pattern: "scrollToMessageId"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/ChatBranchSelector.tsx"
via: "renders branch selector above message list"
pattern: "ChatBranchSelector"
- from: "ui/src/components/ChatMessage.tsx"
to: "ui/src/components/ChatMessageBookmark.tsx"
via: "renders bookmark icon in message actions"
pattern: "ChatMessageBookmark"
---
<objective>
Wire all Phase 24 components into the existing ChatPanel, ChatMessage, CommandPalette, and ChatPanelContext. Connect search navigation, bookmark toggle, branch creation on edit, export triggers, and scroll-to-message.
Purpose: Final integration — turns standalone components into a working user experience.
Output: Fully functional search, bookmarks, branching, and export features.
</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/24-search-history-branching/24-RESEARCH.md
@.planning/phases/24-search-history-branching/24-00-SUMMARY.md
@.planning/phases/24-search-history-branching/24-01-SUMMARY.md
@.planning/phases/24-search-history-branching/24-02-SUMMARY.md
@ui/src/context/ChatPanelContext.tsx
@ui/src/components/ChatPanel.tsx
@ui/src/components/ChatMessage.tsx
@ui/src/components/ChatMessageActions.tsx
@ui/src/components/ChatMessageList.tsx
@ui/src/components/ChatConversationList.tsx
@ui/src/components/CommandPalette.tsx
@ui/src/hooks/useKeyboardShortcuts.ts
<interfaces>
<!-- From Plan 02 components: -->
ChatSearchDialog: { open, onOpenChange, companyId, onNavigate: (conversationId, messageId) => void }
ChatMessageBookmark: { messageId, conversationId, isBookmarked, onToggle }
ChatBookmarkList: { companyId, onNavigate: (conversationId, messageId) => void }
ChatBranchSelector: { conversationId, branches, activeBranchId, onSelectBranch }
<!-- From Plan 02 hooks: -->
useChatSearch(companyId, query) => { data, isLoading }
useChatBookmarks(companyId, conversationId?) => { data, isLoading }
useToggleBookmark() => { mutate(vars), isPending }
<!-- From Plan 02 API: -->
chatApi.branchConversation(conversationId, branchFromMessageId) => ChatConversation
chatApi.listBranches(conversationId) => { items: ChatConversation[] }
chatApi.exportConversation(conversationId, format) => URL string
<!-- From Plan 01 routes: -->
POST /conversations/:id/branch { branchFromMessageId }
GET /conversations/:id/branches
GET /conversations/:id/export?format=markdown|json
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: ChatPanelContext + ChatPanel wiring (search, branch, export, scroll-to)</name>
<files>
ui/src/context/ChatPanelContext.tsx,
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/components/CommandPalette.tsx,
ui/src/components/ChatConversationList.tsx
</files>
<read_first>
ui/src/context/ChatPanelContext.tsx,
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/components/CommandPalette.tsx,
ui/src/components/ChatConversationList.tsx,
ui/src/hooks/useKeyboardShortcuts.ts,
ui/src/hooks/useChatSearch.ts,
ui/src/hooks/useChatBookmarks.ts,
ui/src/components/ChatSearchDialog.tsx,
ui/src/components/ChatBranchSelector.tsx,
ui/src/components/ChatBookmarkList.tsx
</read_first>
<action>
**ChatPanelContext.tsx — add scrollToMessageId:**
- Add to interface: `scrollToMessageId: string | null; setScrollToMessageId: (id: string | null) => void;`
- Add state: `const [scrollToMessageId, setScrollToMessageId] = useState<string | null>(null);`
- Add to provider value object
**CommandPalette.tsx — add "Search chat messages" item:**
- Add a new `CommandItem` in the existing command list: value "search-chat", label "Search chat messages", icon `Search` from lucide-react
- On select: dispatch custom event `window.dispatchEvent(new CustomEvent("nexus:open-chat-search"))` and close the palette
- This avoids Cmd+K conflict (Pitfall 3 from research) — routes through existing CommandPalette
**ChatPanel.tsx — integrate search dialog, branch selector, export, bookmarks:**
- Add state: `const [searchOpen, setSearchOpen] = useState(false)`
- Listen for `nexus:open-chat-search` custom event: `useEffect` that adds event listener, sets `setSearchOpen(true)`, returns cleanup
- Render `<ChatSearchDialog>` with `onNavigate` that: sets `setActiveConversationId(conversationId)`, then `setScrollToMessageId(messageId)`, closes dialog
- Fetch branches for active conversation: `useQuery(["chat", "branches", activeConversationId], () => chatApi.listBranches(activeConversationId!), { enabled: !!activeConversationId })`
- Render `<ChatBranchSelector>` above `ChatMessageList` when branches exist — `onSelectBranch` calls `setActiveConversationId(branchId)`
- Add export buttons (Markdown/JSON) in the conversation header area (next to title). Use `Download` icon. On click: `window.location.href = chatApi.exportConversation(activeConversationId!, format)`
- Add a bookmarks panel toggle (show/hide `ChatBookmarkList`): small `Bookmark` icon button in header. When active, shows `ChatBookmarkList` in a side panel or below the header
- `ChatBookmarkList` `onNavigate` wired same as search: `setActiveConversationId` + `setScrollToMessageId`
- Modify `handleEdit` callback: When editing a message that already has subsequent messages (assistant reply exists after the edited message), call `chatApi.branchConversation(activeConversationId, messageId)` FIRST, then switch to the new branch via `setActiveConversationId(newConv.id)`, then proceed with the edit on the new branch. This is the branching trigger per CHAT-14.
- After any edit/retry, invalidate `["chat", "search"]` queries (per Pitfall 6)
**ChatMessageList.tsx — scroll-to-message support:**
- Get `scrollToMessageId` and `setScrollToMessageId` from `useChatPanel()`
- When `scrollToMessageId` changes (useEffect): find the message index in the flattened messages array, call `virtualizer.scrollToIndex(index, { align: "center" })`, then `setScrollToMessageId(null)` to reset
- If message not found in current page, this is a best-effort scroll (message may not be loaded yet). Add a TODO comment for infinite-scroll-then-scroll-to in a future iteration.
**ChatConversationList.tsx — branch indicators:**
- Read `parentConversationId` and `branchFromMessageId` from each conversation item
- If `parentConversationId` is set, render a small `GitBranch` icon next to the conversation title
- Optionally indent branch conversations under their parent (client-side grouping: group by parentConversationId, then render parent followed by its children)
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "scrollToMessageId" ui/src/context/ChatPanelContext.tsx
- grep -q "setScrollToMessageId" ui/src/context/ChatPanelContext.tsx
- grep -q "nexus:open-chat-search" ui/src/components/CommandPalette.tsx
- grep -q "Search chat" ui/src/components/CommandPalette.tsx
- grep -q "ChatSearchDialog" ui/src/components/ChatPanel.tsx
- grep -q "ChatBranchSelector" ui/src/components/ChatPanel.tsx
- grep -q "exportConversation" ui/src/components/ChatPanel.tsx
- grep -q "branchConversation" ui/src/components/ChatPanel.tsx
- grep -q "scrollToMessageId" ui/src/components/ChatMessageList.tsx
- grep -q "scrollToIndex" ui/src/components/ChatMessageList.tsx
- grep -q "GitBranch" ui/src/components/ChatConversationList.tsx
</acceptance_criteria>
<done>ChatPanelContext has scrollToMessageId. CommandPalette has "Search chat messages" item that dispatches custom event. ChatPanel integrates search dialog, branch selector, export buttons, and bookmark panel. ChatMessageList scrolls to a target message. ChatConversationList shows branch indicator icons. Edit-with-responses triggers branch creation.</done>
</task>
<task type="auto">
<name>Task 2: ChatMessage + ChatMessageActions bookmark integration</name>
<files>
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatMessageActions.tsx
</files>
<read_first>
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatMessageActions.tsx,
ui/src/components/ChatMessageBookmark.tsx,
ui/src/hooks/useChatBookmarks.ts
</read_first>
<action>
**ChatMessageActions.tsx:**
- Add props: `onBookmark?: () => void; isBookmarked?: boolean`
- Import `ChatMessageBookmark` component
- Add `<ChatMessageBookmark>` as the LAST action button (after edit/retry), visible for both user and assistant messages (not system)
- Pass `isBookmarked` and `onToggle={onBookmark}` from props
- Only render bookmark button when `onBookmark` is provided (same pattern as onEdit/onRetry)
**ChatMessage.tsx:**
- Add props: `onBookmark?: (messageId: string) => void; isBookmarked?: boolean`
- Pass `onBookmark={() => id && onBookmark?.(id)}` and `isBookmarked` to `ChatMessageActions`
- Do NOT render bookmark for system messages (messageType checks)
The actual `onBookmark` callback wiring from ChatPanel (calling `useToggleBookmark`) and the `isBookmarked` state (from `useChatBookmarks` data) will be set up in ChatPanel.tsx (Task 1 handles ChatPanel, but the bookmark prop threading from ChatPanel -> ChatMessageList -> ChatMessage needs to be connected).
**In ChatPanel.tsx (addendum to Task 1 wiring):**
- Use `useChatBookmarks(companyId, activeConversationId)` to get bookmark data for active conversation
- Use `useToggleBookmark()` mutation
- Create `bookmarkedMessageIds` Set from bookmark data for O(1) lookup
- Pass `onBookmark={(messageId) => toggleBookmark({ conversationId: activeConversationId!, messageId })}` and `isBookmarked={bookmarkedMessageIds.has(messageId)}` through ChatMessageList to each ChatMessage
- This means ChatMessageList also needs `onBookmark` and `bookmarkedMessageIds` props threaded through
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- grep -q "onBookmark" ui/src/components/ChatMessageActions.tsx
- grep -q "isBookmarked" ui/src/components/ChatMessageActions.tsx
- grep -q "ChatMessageBookmark" ui/src/components/ChatMessageActions.tsx
- grep -q "onBookmark" ui/src/components/ChatMessage.tsx
- grep -q "isBookmarked" ui/src/components/ChatMessage.tsx
- grep -q "useToggleBookmark" ui/src/components/ChatPanel.tsx
- grep -q "bookmarkedMessageIds" ui/src/components/ChatPanel.tsx
</acceptance_criteria>
<done>ChatMessageActions renders a bookmark toggle button for user and assistant messages. ChatMessage threads onBookmark and isBookmarked props. ChatPanel manages bookmark state via useChatBookmarks and useToggleBookmark, passing bookmarkedMessageIds set down through ChatMessageList to each ChatMessage.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify complete Phase 24 functionality</name>
<files>none</files>
<action>
Human verifies all Phase 24 features end-to-end in the browser:
1. Open Nexus in browser. Open the chat panel.
2. Search: Press Cmd+K, find "Search chat messages", select it. Type a term. Verify results with snippets. Click a result — verify navigation and scroll-to-message.
3. Bookmarks: Hover a message, click bookmark icon (fills in). Check bookmarks list in header. Navigate from bookmark. Un-bookmark.
4. Branching: Edit a mid-conversation message. Verify branch created, branch icon in list, branch selector appears. Switch branches.
5. Export: Click export in header. Download Markdown (agent names, not UUIDs). Download JSON (all messages).
</action>
<verify>Human confirms all 4 features work: search, bookmarks, branching, export.</verify>
<done>User types "approved" confirming all Phase 24 success criteria are met.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` passes
- Cmd+K > "Search chat messages" opens ChatSearchDialog
- Search results navigate to conversation + scroll to message
- Bookmark toggle works on user and assistant messages
- Edit-with-responses creates branch conversation
- Branch selector switches between original and branch
- Export downloads Markdown/JSON with agent names
</verification>
<success_criteria>
All four Phase 24 features are integrated and functional: (1) FTS search via Cmd+K with scroll-to-message navigation, (2) bookmark toggle on messages with bookmark list panel, (3) conversation branching on edit with branch selector UI, (4) export as Markdown/JSON with agent names. Human verification confirms all flows work end-to-end.
</success_criteria>
<output>
After completion, create `.planning/phases/24-search-history-branching/24-03-SUMMARY.md`
</output>