---
phase: 24-search-history-branching
verified: 2026-04-01T00:00:00Z
status: gaps_found
score: 3/4 success criteria verified
gaps:
  - truth: "User can export any conversation as a Markdown file or as a JSON file"
    status: partial
    reason: "Server supports both formats (markdown + json) and chatApi.exportConversation accepts format param, but ChatPanel only renders a single 'Export as Markdown' button (calls handleExport('markdown')). No UI control triggers JSON export."
    artifacts:
      - path: "ui/src/components/ChatPanel.tsx"
        issue: "Only one export button at line 261: onClick={() => handleExport('markdown')}. JSON export path (handleExport('json')) is never called from any UI element."
    missing:
      - "Add a second export button (or dropdown) in ChatPanel header to trigger handleExport('json') — the handler, API client method, server route, and service all exist and work; only the UI trigger is missing"
human_verification:
  - test: "Confirm Cmd+K -> 'Search chat messages' -> type query -> click result scrolls to the correct message"
    expected: "Search overlay opens, results appear for matching terms within ~500ms, clicking a result switches to that conversation and the virtualizer scrolls to the target message"
    why_human: "Cannot test keyboard shortcuts, overlay rendering, or virtualizer scroll behavior programmatically without running the app"
  - test: "Confirm bookmark toggle persists: hover a message, click the bookmark icon (fills solid), refresh page, verify icon is still filled"
    expected: "Bookmark state survives page reload, indicating the POST /conversations/:id/bookmarks endpoint is being called and the DB write is real"
    why_human: "Requires live browser interaction and server running"
  - test: "Confirm branch-on-edit: send two messages, click edit on the first, submit — verify a new branch conversation appears in the sidebar with a GitBranch icon"
    expected: "Branch conversation is created, sidebar shows it indented under the original with a GitBranch indicator, branch selector bar appears above the message list"
    why_human: "Requires live server, active agent streaming session, and visual inspection of the sidebar grouping"
  - test: "Confirm Markdown export downloads a .md file with agent names (not UUIDs) in the headers"
    expected: "Browser downloads a file named <slug>-<date>.md; assistant messages show agent name (e.g. 'Brainstormer') not a UUID"
    why_human: "Requires browser file download and manual inspection of content"
---

# Phase 24: Search, History & Branching — Verification Report

**Phase Goal:** Users can find any message across all conversations in under 500ms, export conversations, bookmark key messages, and branch from any point in a conversation
**Verified:** 2026-04-01
**Status:** gaps_found — 1 gap blocking complete goal achievement
**Re-verification:** No — initial verification

---

## Goal Achievement

### Success Criteria (from ROADMAP.md)

| # | Criterion | Status | Evidence |
|---|-----------|--------|----------|
| 1 | Cmd+K opens search overlay; results returned in <500ms across 10,000+ messages | ? HUMAN NEEDED | FTS pipeline fully wired (tsvector GIN index → plainto_tsquery → ts_rank → ChatSearchDialog → CommandPalette custom event); runtime perf requires human test |
| 2 | User can bookmark any message and navigate to bookmarked messages | ✓ VERIFIED | ChatMessageBookmark renders on every message via ChatMessageActions; useToggleBookmark mutation calls POST /bookmarks; ChatBookmarkList with onNavigate wired into ChatPanel |
| 3 | Editing a message with a response creates a branch; user can switch branches | ✓ VERIFIED | handleEdit checks editedIdx < messages.length - 1 then calls chatApi.branchConversation; ChatBranchSelector rendered when branches.length > 0; ChatConversationList shows GitBranch icon + pl-4 indent |
| 4 | User can export as Markdown **or** JSON | ✗ FAILED | Server route and service support both formats; chatApi.exportConversation accepts format param; ChatPanel only exposes markdown button — JSON export has no UI trigger |

**Score:** 3/4 success criteria verified (criterion 1 needs human runtime check; criterion 4 has a confirmed code gap)

---

## Required Artifacts

### Plan 00 — DB Migrations, Schema, Shared Types

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `packages/db/src/migrations/0050_add_branch_columns.sql` | Branch columns migration | ✓ VERIFIED | Contains `parent_conversation_id` FK + `branch_from_message_id` + index |
| `packages/db/src/migrations/0051_add_message_search_vector.sql` | tsvector + GIN index migration | ✓ VERIFIED | `content_search` generated column + GIN index present |
| `packages/db/src/migrations/0052_create_chat_message_bookmarks.sql` | Bookmarks table migration | ✓ VERIFIED | Table + two compound indexes for company+message and company+conversation |
| `packages/db/src/schema/chat_message_bookmarks.ts` | Drizzle schema for bookmarks | ✓ VERIFIED | `chatMessageBookmarks` exported; compound indexes defined |
| `packages/db/src/schema/chat_conversations.ts` | Branch columns added | ✓ VERIFIED | `parentConversationId` with `AnyPgColumn` for self-referential FK; `branchFromMessageId`; `parentIdx` |
| `packages/db/src/schema/index.ts` | Re-exports chatMessageBookmarks | ✓ WIRED | Line 61: `export { chatMessageBookmarks } from "./chat_message_bookmarks.js"` |
| `packages/shared/src/types/chat.ts` | Search, bookmark, branch types | ✓ VERIFIED | `ChatMessageSearchResult`, `ChatBookmark`, `ChatBookmarkWithMessage`, `ChatBookmarkToggleResponse`; `parentConversationId` on `ChatConversation` |
| `packages/shared/src/validators/chat.ts` | searchMessagesSchema, branchConversationSchema | ✓ VERIFIED | Both validators present with correct shapes |
| `packages/shared/src/index.ts` | Re-exports new types and validators | ✓ WIRED | Lines 565–566, 581: all new symbols exported |
| `server/src/__tests__/chat-service.test.ts` | Wave 0 test stubs | ✓ VERIFIED | 4 describe blocks with it.todo entries (searchMessages, toggleBookmark, branchConversation, exportConversation) |

### Plan 01 — Server Service Methods and Routes

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `server/src/services/chat.ts` | Six service methods | ✓ VERIFIED | searchMessages (tsvector FTS), toggleBookmark (transactional), getBookmarks (join), branchConversation (message copy), listBranches, exportConversation (agent LEFT JOIN) |
| `server/src/routes/chat.ts` | Six route handlers | ✓ VERIFIED | GET /messages/search, POST /bookmarks, GET /bookmarks, POST /branch, GET /branches, GET /export — all with assertBoard guard |

### Plan 02 — UI API Client, Hooks, Components

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `ui/src/api/chat.ts` | Six new chatApi methods | ✓ VERIFIED | searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation (returns URL string) |
| `ui/src/hooks/useChatSearch.ts` | Debounced FTS query hook | ✓ VERIFIED | placeholderData, staleTime: 30_000, enabled when query >= 2 chars |
| `ui/src/hooks/useChatBookmarks.ts` | Bookmark query + mutation hooks | ✓ VERIFIED | useChatBookmarks + useToggleBookmark; invalidates both `["chat","bookmarks"]` and `["chat","search"]` |
| `ui/src/components/ChatSearchDialog.tsx` | CommandDialog FTS overlay | ✓ VERIFIED | Uses CommandDialog, shouldFilter={false}, useChatSearch, HighlightedText (XSS-safe) |
| `ui/src/components/ChatMessageBookmark.tsx` | Bookmark toggle button | ✓ VERIFIED | fill-current on isBookmarked; aria-label toggles; ghost h-6 w-6 sizing |
| `ui/src/components/ChatBookmarkList.tsx` | Scrollable bookmark list | ✓ VERIFIED | useChatBookmarks; skeleton loading; empty state; onNavigate callback |
| `ui/src/components/ChatBranchSelector.tsx` | Horizontal branch picker | ✓ VERIFIED | GitBranch icon; bg-accent for active; renders null when no branches |

### Plan 03 — Integration Wiring

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `ui/src/context/ChatPanelContext.tsx` | scrollToMessageId state | ✓ VERIFIED | State + setter in interface, provider value, and useState |
| `ui/src/components/ChatPanel.tsx` | Full feature integration | ✓ VERIFIED | ChatSearchDialog, ChatBranchSelector, export (markdown only — see gap), bookmarks panel, branch-on-edit, useToggleBookmark, bookmarkedMessageIds Set |
| `ui/src/components/ChatMessageList.tsx` | Scroll-to-message support | ✓ VERIFIED | useEffect on scrollToMessageId; virtualizer.scrollToIndex(index, {align:"center"}); resets to null after scroll |
| `ui/src/components/ChatMessageActions.tsx` | Bookmark button on messages | ✓ VERIFIED | ChatMessageBookmark rendered as last action; onBookmark + isBookmarked props |
| `ui/src/components/ChatMessage.tsx` | Bookmark prop threading | ✓ VERIFIED | onBookmark={id && onBookmark ? () => onBookmark(id) : undefined} — real messageId passed |
| `ui/src/components/CommandPalette.tsx` | "Search chat messages" command item | ✓ VERIFIED | value="search-chat"; dispatches `nexus:open-chat-search` custom event |
| `ui/src/components/ChatConversationList.tsx` | Branch indicators | ✓ VERIFIED | GitBranch icon + pl-4 indent when parentConversationId is non-null |

---

## Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `packages/db/src/schema/chat_message_bookmarks.ts` | `packages/db/src/schema/index.ts` | re-export | ✓ WIRED | Line 61 of schema/index.ts |
| `packages/shared/src/types/chat.ts` | `packages/shared/src/index.ts` | re-export | ✓ WIRED | Lines 565–581 |
| `server/src/routes/chat.ts` | `server/src/services/chat.ts` | svc.searchMessages, svc.toggleBookmark, svc.branchConversation, svc.exportConversation | ✓ WIRED | All four service calls confirmed in route handlers |
| `server/src/services/chat.ts` | `packages/db/src/schema/chat_message_bookmarks.ts` | import chatMessageBookmarks | ✓ WIRED | Line 3 of services/chat.ts |
| `ui/src/hooks/useChatSearch.ts` | `ui/src/api/chat.ts` | chatApi.searchMessages | ✓ WIRED | Line 7 of useChatSearch.ts |
| `ui/src/components/ChatSearchDialog.tsx` | `ui/src/hooks/useChatSearch.ts` | useChatSearch | ✓ WIRED | Line 3 import + line 78 usage |
| `ui/src/components/ChatMessageBookmark.tsx` | `ui/src/hooks/useChatBookmarks.ts` | useToggleBookmark | ✓ WIRED | Indirectly — onToggle is wired at ChatPanel → handleBookmark → toggleBookmark; ChatMessageBookmark itself only needs onToggle callback |
| `ui/src/components/CommandPalette.tsx` | `ui/src/components/ChatSearchDialog.tsx` | nexus:open-chat-search custom event | ✓ WIRED | CommandPalette dispatches; ChatPanel listens and sets searchOpen(true) |
| `ui/src/context/ChatPanelContext.tsx` | `ui/src/components/ChatMessageList.tsx` | scrollToMessageId | ✓ WIRED | useChatPanel() in ChatMessageList; useEffect scrolls virtualizer |
| `ui/src/components/ChatPanel.tsx` | `ui/src/components/ChatBranchSelector.tsx` | branches data from useQuery | ✓ WIRED | listBranches query feeds ChatBranchSelector; onSelectBranch calls setActiveConversationId |
| `ui/src/components/ChatMessage.tsx` | `ui/src/components/ChatMessageBookmark.tsx` (via ChatMessageActions) | onBookmark prop chain | ✓ WIRED | ChatPanel → handleBookmark → ChatMessageList → ChatMessage → ChatMessageActions → ChatMessageBookmark.onToggle |
| `ui/src/components/ChatPanel.tsx` | JSON export | handleExport("json") | ✗ NOT_WIRED | handleExport callback accepts "json" but no UI element calls it; only markdown button rendered |

---

## Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `server/src/services/chat.ts` :: searchMessages | rows | Drizzle query with tsvector @@ plainto_tsquery, ts_rank ORDER BY, LIMIT | Yes — real DB join + FTS WHERE clause | ✓ FLOWING |
| `server/src/services/chat.ts` :: toggleBookmark | existing | SELECT then INSERT or DELETE in transaction | Yes — real DB read-modify-write | ✓ FLOWING |
| `server/src/services/chat.ts` :: branchConversation | messagesToCopy | SELECT lte(createdAt) + INSERT returning | Yes — real DB copy + returns new conversation | ✓ FLOWING |
| `server/src/services/chat.ts` :: exportConversation | rows | SELECT + LEFT JOIN agents | Yes — all messages + agent names from DB | ✓ FLOWING |
| `ui/src/hooks/useChatSearch.ts` | data | chatApi.searchMessages → GET /companies/:id/messages/search | Yes — enabled when query >= 2 chars, real endpoint | ✓ FLOWING |
| `ui/src/hooks/useChatBookmarks.ts` | data | chatApi.getBookmarks → GET /companies/:id/bookmarks | Yes — real endpoint | ✓ FLOWING |
| `ui/src/components/ChatPanel.tsx` :: bookmarkedMessageIds | Set<string> | useChatBookmarks(companyId, activeConversationId).data | Yes — rebuilt per-conversation from server data | ✓ FLOWING |
| `ui/src/components/ChatPanel.tsx` :: branches | ChatConversation[] | useQuery → chatApi.listBranches → GET /conversations/:id/branches | Yes — real endpoint | ✓ FLOWING |
| `ui/src/components/ChatPanel.tsx` :: JSON export | — | handleExport("json") | N/A — UI trigger missing | ✗ DISCONNECTED (no UI entry point) |

---

## Behavioral Spot-Checks

| Behavior | Method | Result | Status |
|----------|--------|--------|--------|
| All 8 Phase 24 commits exist in git | git log for 8 commit hashes | All 8 found (430bbbb8 through 2b526e78) | ✓ PASS |
| Migration journal has 0050/0051/0052 entries | grep in _journal.json | All three tags present | ✓ PASS |
| Shared package exports key symbols | node -e require check | ChatMessageSearchResult, ChatBookmark, searchMessagesSchema, branchConversationSchema — all FOUND | ✓ PASS |
| searchMessages has real FTS WHERE clause | grep in service | `"content_search" @@ plainto_tsquery` with ts_rank ORDER BY found | ✓ PASS |
| export route sets Content-Disposition header | grep in routes | Line 273: res.setHeader("Content-Disposition", ...) confirmed | ✓ PASS |
| JSON export has UI trigger in ChatPanel | grep for handleExport("json") | Only markdown button at line 261; no JSON trigger | ✗ FAIL |

---

## Requirements Coverage

| Requirement | Source Plan(s) | Description | Status | Evidence |
|-------------|---------------|-------------|--------|----------|
| CHAT-07 | 00, 01, 02, 03 | Full-text search across all conversations | ✓ SATISFIED | tsvector GIN index, plainto_tsquery service, ChatSearchDialog, Cmd+K integration all wired |
| CHAT-13 | 00, 01, 02, 03 | Message bookmarks: mark important messages | ✓ SATISFIED | ChatMessageBookmark on every message, toggleBookmark service+route, ChatBookmarkList navigation |
| CHAT-14 | 01, 03 | Conversation branching on edit | ✓ SATISFIED | branchConversation service, POST /branch route, handleEdit branch-on-edit logic, ChatBranchSelector |
| HIST-04 | 00, 01, 02, 03 | Conversation export as Markdown or JSON | ✗ BLOCKED | Server and API client support both formats; ChatPanel only exposes Markdown button — JSON has no UI trigger |
| PERF-04 | 01 | FTS returns results <500ms across 10,000+ messages | ? NEEDS HUMAN | GIN index in place; ts_rank ordering correct; runtime perf requires load test or browser timing |

### Phantom Requirement IDs in Plan Frontmatter

The following requirement IDs appear in plan frontmatter (`24-00-PLAN.md` through `24-03-PLAN.md`) but **do not exist** in `REQUIREMENTS.md`:

- `HIST-07`, `HIST-08`, `HIST-09`, `HIST-10`, `HIST-11`, `HIST-12`

`REQUIREMENTS.md` defines only HIST-01 through HIST-06. These IDs were invented in the plan documents but have no corresponding requirement definitions. The authoritative requirement set for Phase 24 per ROADMAP.md is: **CHAT-07, CHAT-13, CHAT-14, HIST-04, PERF-04** — all of which are accounted for above.

---

## Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `ui/src/components/ChatMessageActions.tsx` | 37, 71 | `messageId=""` and `conversationId=""` passed to `ChatMessageBookmark` | ℹ️ Info | Not a functional stub — `ChatMessageBookmark` does not use these props in its body (destructures only `isBookmarked` and `onToggle`); real messageId flows via `onToggle` callback. Props are dead interface noise, not a bug. |
| `ui/src/components/ChatMessageList.tsx` | (comment) | `// TODO: if message not found, best-effort only` | ℹ️ Info | Intentional — plan specified this as acceptable for scroll-to on unpaginated messages. Does not block any success criterion. |

No blocking stubs found. All core implementations contain real DB queries, real HTTP calls, and real state management.

---

## Human Verification Required

### 1. Full-text search round-trip with scroll-to-message

**Test:** Press Cmd+K in the Nexus UI. Select "Search chat messages". Type a search term that exists in a past conversation message. Verify results appear with conversation title and message snippet. Click a result.
**Expected:** ChatSearchDialog closes; the chat panel switches to the target conversation; the virtualizer scrolls to the target message (centered in view) within ~500ms total.
**Why human:** Keyboard shortcut dispatch, dialog rendering, virtualizer scroll, and sub-500ms timing cannot be verified without a running browser session.

### 2. Bookmark persistence across page reload

**Test:** Open a conversation. Hover a message. Click the bookmark icon (should fill solid). Reload the page. Return to the same conversation and hover the same message.
**Expected:** The bookmark icon is still filled, confirming the DB write via POST /conversations/:id/bookmarks persisted and the GET /companies/:id/bookmarks query on reload returns it.
**Why human:** Requires live server, DB write, and page reload cycle.

### 3. Branch-on-edit creates visible branch in sidebar

**Test:** In a conversation with at least two messages (one user, one assistant reply), click edit on the user message, change the text, and submit.
**Expected:** A new branch conversation appears in the sidebar with a GitBranch icon and pl-4 indent under the original. The branch selector bar appears above the message list. Clicking "Original" in the branch selector switches back to the original conversation.
**Why human:** Requires an active agent session, streaming, and visual sidebar inspection.

### 4. Markdown export produces correct agent names (not UUIDs)

**Test:** In a conversation that involved a named agent, click the Download icon in the chat header. Open the downloaded .md file.
**Expected:** The file is named `<title-slug>-<date>.md`; assistant messages show the agent's name (e.g. "Brainstormer") not a UUID; user messages show "You"; format matches `**Speaker** (timestamp)\ncontent\n\n---`.
**Why human:** Requires browser file download and manual inspection of content.

---

## Gaps Summary

**1 gap blocks full goal achievement:**

**JSON export has no UI trigger (Success Criterion 4, HIST-04)**

The complete JSON export pipeline exists — the server service (`exportConversation`), the Express route (`GET /conversations/:id/export?format=json`), and the API client method (`chatApi.exportConversation(id, "json")`) all work correctly. The `handleExport` callback in ChatPanel also accepts "json" as a format. However, `ChatPanel.tsx` only renders one export button (line 261) that hardcodes `handleExport("markdown")`. No UI element triggers `handleExport("json")`.

**Fix required:** Add a second export button (or a dropdown with two options) in the ChatPanel header that calls `handleExport("json")`. The server, service, route, API client, and handler are all already correct — only the UI trigger is missing.

---

_Verified: 2026-04-01_
_Verifier: Claude (gsd-verifier)_
