# Phase 21: Chat Foundation - Research

**Researched:** 2026-04-01
**Domain:** Persistent chat UI with markdown rendering, DB schema, and theme-aware code highlighting
**Confidence:** HIGH

## Summary

Phase 21 establishes the structural foundation for all subsequent chat phases: database tables for conversations and messages, a REST API for CRUD operations on those tables, and the React UI layer (chat drawer + sidebar conversation list + message renderer). It does NOT include agent execution or streaming — those land in Phase 22. The entire phase is UI-plus-persistence: create a conversation, post static messages, render them with full markdown fidelity, and reload without data loss.

The codebase already contains every necessary supporting primitive. The database layer uses Drizzle ORM with PostgreSQL (not libSQL — the PRD used that term loosely; the running system is PostgreSQL 17). The UI already has `MarkdownBody` (`ui/src/components/MarkdownBody.tsx`) using `react-markdown` + `remark-gfm` + `mermaid`, but without syntax highlighting for code blocks — that gap must be closed here (CHAT-02/03). The `PropertiesPanel` / `PanelContext` pattern demonstrates exactly how a right-side drawer should be wired. Theme integration requires no new plumbing; `useTheme()` + `THEME_META` is already the authoritative system.

**Primary recommendation:** Add two new Drizzle schema files (`chat_conversations` + `chat_messages`), generate and run a migration, create service+route files following the existing factory pattern, and add a `ChatPanel` component that re-uses `PanelContext` open/close state (or a new dedicated `ChatPanelContext` keyed to `localStorage`).

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
None — discuss phase was skipped per user setting (`workflow.skip_discuss: true`).

### Claude's Discretion
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.

### Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped.
</user_constraints>

<phase_requirements>
## Phase Requirements

Per ROADMAP.md (authoritative, overrides the broader CHAT-01..11 list in the task prompt):

| ID | Description | Research Support |
|----|-------------|------------------|
| CHAT-02 | Markdown rendering: code blocks with syntax highlighting, tables, lists, headings, links, images | Existing `MarkdownBody` covers most; syntax highlighting needs `rehype-highlight` or `react-syntax-highlighter` added |
| CHAT-03 | Code blocks: one-click copy button and language label | Custom `pre`/`code` component override in `MarkdownBody` extensions |
| CHAT-04 | Multiple concurrent conversations: sidebar shows full list | `chat_conversations` table + `/api/companies/:id/conversations` GET endpoint + sidebar React component |
| CHAT-05 | Conversation titles: auto-generated from first message, manually editable | `title` column on `chat_conversations`; auto-generated server-side on first message insert; PATCH endpoint |
| CHAT-06 | Delete, archive, pin conversations | `deletedAt`, `archivedAt`, `pinnedAt` nullable timestamps on `chat_conversations` |
| INPUT-01 | Multi-line input with auto-resize: grows with content up to max height | `<textarea>` with CSS `field-sizing: content` or `rows` auto-expand hook |
| INPUT-07 | Keyboard shortcuts: Enter to send, Shift+Enter for newline, Escape to cancel | `onKeyDown` handler on textarea |
| HIST-01 | All conversations persisted in PostgreSQL (codebase uses PG, not libSQL) | Two new Drizzle schema files + migration |
| HIST-02 | Conversation list in sidebar: sorted by most recent, searchable, filterable by agent | Server-side sort by `updatedAt DESC`; client-side filter/search on loaded list |
| HIST-03 | Infinite scroll in conversation list sidebar | TanStack Query `useInfiniteQuery` with cursor pagination |
| HIST-05 | Cross-device sync: conversations accessible from any device on network | Covered by server API — no extra work; Nexus is already a server |
| HIST-06 | Chat history survives server restarts: no in-memory-only state | Covered by DB persistence; no in-memory chat state |
| THEME-01 | Chat interface respects Nexus theme system | Reuse `useTheme()` + CSS variables already in `index.css` |
| THEME-02 | Code blocks use theme-appropriate syntax highlighting | Pass `THEME_META[theme].dark` to syntax highlighter; use Catppuccin/Tokyo Night themes |
</phase_requirements>

---

## Project Constraints (from CLAUDE.md)

| Constraint | Detail |
|-----------|--------|
| Upstream sync | Display-layer changes only. DB schema, API routes, code identifiers, token formats must be upstream-compatible. New tables are additive and safe. |
| No data migration | No changes to existing tables. New tables only — no column changes to existing schema. |
| Deploy target | Mac Mini M4, `local_trusted` mode, single user |
| Language | TypeScript (ESM) everywhere. No plain JS. |
| Package manager | pnpm 9.15.4. Use `pnpm add` — never `npm install`. |
| Framework | Express 5.1.0 routes must follow `function fooRoutes(db: Db): Router` factory pattern |
| DB | Drizzle ORM with PostgreSQL. Generate migration with `pnpm db:generate` then commit migration SQL. |
| Auth | `local_trusted` mode means `assertBoard(req)` is the only auth gate needed |
| Testing | Vitest (server) + React Testing Library (UI). Service tests use `vi.mock` pattern shown in `activity-routes.test.ts`. |

---

## Standard Stack

### Core (already in project, no install needed)
| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| `drizzle-orm` | ^0.38.4 | Schema definition + query builder | Use existing pattern from `documents.ts` |
| `react` | ^19.0.0 | UI component layer | — |
| `react-markdown` | ^10.1.0 | Already in `ui/package.json` | Basis of `MarkdownBody` |
| `remark-gfm` | ^4.0.1 | GFM tables/lists/strikethrough | Already used in `MarkdownBody` |
| `@tanstack/react-query` | ^5.x | Server state, pagination | `useInfiniteQuery` for conversation list |
| `lucide-react` | ^0.574.0 | Icons (MessageSquare, Pin, Archive, Trash2, Plus, etc.) | — |
| `tailwind-merge` / `clsx` | current | Conditional classNames | — |

### Additions required
| Library | Version | Purpose | Install |
|---------|---------|---------|---------|
| `rehype-highlight` | 7.0.2 (current) | Syntax highlighting via highlight.js in react-markdown | `pnpm --filter @paperclipai/ui add rehype-highlight highlight.js` |
| `highlight.js` | — (peer of rehype-highlight) | Highlight.js core — provides Catppuccin/Tokyo Night themes | pulled in by rehype-highlight |

**Why `rehype-highlight` over `react-syntax-highlighter`:**
- `rehype-highlight` integrates cleanly with `react-markdown` via the `rehypePlugins` prop — no custom component overrides needed per language
- Highlight.js ships Catppuccin Mocha, Catppuccin Latte, and Tokyo Night CSS themes natively (as of hljs 11.x), avoiding custom CSS
- Smaller bundle than `react-syntax-highlighter` which bundles Prism + all languages
- Confidence: HIGH — verified against react-markdown docs and highlight.js theme list

**Alternatives considered:**
| Instead of | Could use | Tradeoff |
|-----------|-----------|----------|
| `rehype-highlight` | `react-syntax-highlighter` | RSH provides per-component control but requires a custom `code` component wrapper; more bundle weight |
| `rehype-highlight` | `shiki` (via `rehype-shiki`) | Shiki produces beautiful output but is heavier (WASM), more complex config, and overkill for this phase |

**Version verification:**
```bash
npm view rehype-highlight version   # 7.0.2
npm view highlight.js version       # 11.x (pulled as transitive dep)
npm view react-markdown version     # 10.1.0 (already installed)
```

**Installation (additions only):**
```bash
pnpm --filter @paperclipai/ui add rehype-highlight
# highlight.js is a dependency of rehype-highlight — comes automatically
```

---

## Architecture Patterns

### Recommended Project Structure (new files only)

```
packages/db/src/schema/
├── chat_conversations.ts   # new — conversation records
└── chat_messages.ts        # new — message records

packages/db/src/migrations/
└── 0047_chat_foundation.sql  # generated by drizzle-kit generate

packages/shared/src/
├── types/chat.ts           # new — ChatConversation, ChatMessage types
└── validators/chat.ts      # new — Zod schemas for create/update

server/src/
├── services/chat.ts        # new — chatService(db) factory
└── routes/chat.ts          # new — chatRoutes(db): Router

ui/src/
├── api/chat.ts             # new — chatApi fetch wrappers
├── context/ChatPanelContext.tsx  # new — open/closed + active conversation
├── components/
│   ├── ChatPanel.tsx        # new — right-side drawer shell
│   ├── ChatConversationList.tsx  # new — sidebar list with infinite scroll
│   ├── ChatMessageList.tsx  # new — message thread
│   ├── ChatInput.tsx        # new — auto-resize textarea
│   └── ChatMarkdownMessage.tsx   # new — MarkdownBody extended with rehype-highlight
└── hooks/
    └── useChatConversations.ts  # new — TanStack Query wrappers
```

### Pattern 1: Drizzle Schema (follow existing pattern)

```typescript
// Source: packages/db/src/schema/documents.ts (existing reference)
// packages/db/src/schema/chat_conversations.ts
import { pgTable, uuid, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";

export const chatConversations = pgTable(
  "chat_conversations",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    companyId: uuid("company_id").notNull().references(() => companies.id),
    title: text("title"),
    agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
    pinnedAt: timestamp("pinned_at", { withTimezone: true }),
    archivedAt: timestamp("archived_at", { withTimezone: true }),
    deletedAt: timestamp("deleted_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => ({
    companyUpdatedIdx: index("chat_conversations_company_updated_idx")
      .on(table.companyId, table.updatedAt),
    companyDeletedIdx: index("chat_conversations_company_deleted_idx")
      .on(table.companyId, table.deletedAt),
  }),
);
```

```typescript
// packages/db/src/schema/chat_messages.ts
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { chatConversations } from "./chat_conversations.js";

export const chatMessages = pgTable(
  "chat_messages",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    conversationId: uuid("conversation_id").notNull()
      .references(() => chatConversations.id, { onDelete: "cascade" }),
    role: text("role").notNull(), // "user" | "assistant" | "system"
    content: text("content").notNull(),
    agentId: uuid("agent_id"), // which agent produced this (null for user messages)
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => ({
    conversationCreatedIdx: index("chat_messages_conversation_created_idx")
      .on(table.conversationId, table.createdAt),
  }),
);
```

**Important:** After creating schema files, export them from `packages/db/src/schema/index.ts` and run:
```bash
pnpm db:generate   # generates SQL migration under packages/db/src/migrations/
pnpm db:migrate    # applies migration to running DB
```

### Pattern 2: Service Factory (follow existing pattern)

```typescript
// Source: server/src/services/documents.ts (existing reference)
// server/src/services/chat.ts
import type { Db } from "@paperclipai/db";
import { chatConversations, chatMessages } from "@paperclipai/db";
import { and, desc, eq, isNull, lt } from "drizzle-orm";

export function chatService(db: Db) {
  return {
    async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) {
      const limit = Math.min(opts.limit ?? 30, 100);
      const rows = await db
        .select()
        .from(chatConversations)
        .where(
          and(
            eq(chatConversations.companyId, companyId),
            isNull(chatConversations.deletedAt),
            opts.cursor
              ? lt(chatConversations.updatedAt, new Date(opts.cursor))
              : undefined,
          ),
        )
        .orderBy(desc(chatConversations.updatedAt))
        .limit(limit + 1);
      const hasMore = rows.length > limit;
      return { items: rows.slice(0, limit), hasMore };
    },
    // createConversation, getConversation, updateConversation, softDeleteConversation,
    // listMessages, addMessage, etc.
  };
}
```

### Pattern 3: Route Factory (follow existing pattern)

```typescript
// Source: server/src/routes/activity.ts (existing reference)
// server/src/routes/chat.ts
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.js";
import { validate } from "../middleware/validate.js";

export function chatRoutes(db: Db) {
  const router = Router();
  const svc = chatService(db);

  // GET  /api/companies/:companyId/conversations
  // POST /api/companies/:companyId/conversations
  // GET  /api/conversations/:id
  // PATCH /api/conversations/:id
  // DELETE /api/conversations/:id
  // GET  /api/conversations/:id/messages
  // POST /api/conversations/:id/messages
  return router;
}
```

Mount in `server/src/app.ts` following the existing route registration list.

### Pattern 4: Chat Panel Context (localStorage-persisted open state)

```typescript
// ui/src/context/ChatPanelContext.tsx
// Mirrors PanelContext.tsx pattern but keyed specifically to chat.
// Key: "nexus:chat-panel-open" (use nexus: prefix not paperclip: to stay in Nexus scope)
```

The chat panel is a **separate right-side drawer** from `PropertiesPanel`. They should not share state. The chat icon in `Layout` (or `CompanyRail`) toggles `chatPanelOpen`. The drawer sits between `<main>` and `<PropertiesPanel>` in the flex row, or on top of it as an overlay — implementation choice.

**Recommended:** Fixed-width right drawer (320–400px) inside the existing `flex` row, hidden with `w-0 overflow-hidden` when closed, using the same CSS transition pattern as the sidebar (`transition-[width] duration-100 ease-out`).

### Pattern 5: Infinite Scroll with TanStack Query

```typescript
// ui/src/hooks/useChatConversations.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { chatApi } from "../api/chat";

export function useChatConversations(companyId: string) {
  return useInfiniteQuery({
    queryKey: ["chat", "conversations", companyId],
    queryFn: ({ pageParam }) =>
      chatApi.listConversations(companyId, { cursor: pageParam as string | undefined }),
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
    initialPageParam: undefined,
  });
}
```

### Pattern 6: Theme-aware Syntax Highlighting

```typescript
// ui/src/components/ChatMarkdownMessage.tsx
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { useTheme, THEME_META } from "../context/ThemeContext";
// Import highlight.js theme CSS dynamically based on active theme:
// catppuccin-mocha → "highlight.js/styles/base16/catppuccin.css" (dark variant)
// tokyo-night      → "highlight.js/styles/tokyo-night-dark.css"
// catppuccin-latte → "highlight.js/styles/base16/catppuccin.css" (light variant)
```

**Code block copy button pattern:** Override the `pre` component in `react-markdown`'s `components` prop. Extract the code text from the child `<code>` element, wire a `useClipboard`/`navigator.clipboard.writeText` handler.

### Pattern 7: Auto-resize Textarea

```typescript
// ui/src/components/ChatInput.tsx
// Modern CSS approach: field-sizing: content (Chrome 123+, Firefox 129+)
// Fallback: ref.current.style.height = 'auto'; ref.current.style.height = ref.current.scrollHeight + 'px'
// onKeyDown: e.key === 'Enter' && !e.shiftKey → submit; e.key === 'Escape' → clear
```

### Anti-Patterns to Avoid

- **Reusing PropertiesPanel for chat:** The `PropertiesPanel` is content-dependent (varies per page). Chat is a persistent global panel. Use a separate context.
- **Storing conversation open/closed state in URL:** Use `localStorage` (as `PanelContext` does). URL state would cause issues on navigation.
- **Using `libSQL`/Turso:** The REQUIREMENTS.md says "libSQL" but the codebase runs PostgreSQL. Ignore the libSQL reference — it is a PRD artifact. Use the existing Drizzle/PG stack.
- **Hand-rolling markdown rendering:** `MarkdownBody` already exists and handles mermaid, GFM, and mention chips. Extend it rather than create a parallel implementation.
- **Inline `highlight.js` CSS:** Load theme CSS via a dynamic `<link>` or via CSS `@import` conditioned on `.dark` / `.theme-tokyo-night` — do not inline all themes at once.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Markdown rendering | Custom parser | `react-markdown` + `remark-gfm` | Already in codebase, battle-tested |
| Syntax highlighting | Token-by-token highlighter | `rehype-highlight` (highlight.js) | Covers 190+ languages, ships Catppuccin/Tokyo Night themes |
| Server state / pagination | Custom fetch + cursor state | `useInfiniteQuery` (TanStack Query) | Already used everywhere in the project |
| Auto-resize textarea | `setInterval` height checks | CSS `field-sizing: content` + scroll height fallback | One-liner with good browser support |
| Right-side drawer animation | Custom JS animation | CSS `transition-[width]` (same as sidebar) | Already proven in Layout.tsx |
| Copy to clipboard | Cross-browser clipboard shim | `navigator.clipboard.writeText` | Sufficient for local trusted mode |

---

## Common Pitfalls

### Pitfall 1: highlight.js theme CSS loading in Vite
**What goes wrong:** Importing CSS from `node_modules/highlight.js/styles/` at the module level causes all three themes to load simultaneously, causing visual conflicts.
**Why it happens:** CSS imports in ESM/Vite are side-effecting; theme CSS from hljs uses global selectors (`.hljs { ... }`).
**How to avoid:** Use a single CSS file that overrides hljs variables per theme class using your existing CSS variable system (`.dark .hljs { ... }`, `.theme-tokyo-night .hljs { ... }`), OR dynamically insert/swap a `<link>` element when `theme` changes.
**Warning signs:** Code blocks always show one theme regardless of active theme switch.

### Pitfall 2: `chat_messages` cascade delete gap
**What goes wrong:** Deleting a conversation (hard delete) leaves orphaned messages.
**Why it happens:** Forgetting to set `{ onDelete: "cascade" }` on the FK.
**How to avoid:** Schema above already includes cascade; verify the generated SQL includes `ON DELETE CASCADE` before committing the migration.

### Pitfall 3: `updatedAt` on conversation not bumped on new message
**What goes wrong:** The conversation list sort by `updatedAt DESC` shows stale ordering after a new message is posted.
**Why it happens:** Drizzle auto-sets `updatedAt` only on direct row updates, not cascading through FK children.
**How to avoid:** In `chatService.addMessage()`, also run an `UPDATE chat_conversations SET updated_at = now() WHERE id = $conversationId`.

### Pitfall 4: PropertiesPanel hidden when chat panel is open
**What goes wrong:** Both panels try to occupy the right side of the layout; one hides the other.
**Why it happens:** Both are `flex-shrink-0` elements in the same row.
**How to avoid:** The chat drawer should be a sibling of `PropertiesPanel` in the layout flex row. When the chat panel is open, `PropertiesPanel` should close (or they should coexist with a combined max-width). Decide at plan time — research suggests coexistence adds complexity; close `PropertiesPanel` when chat opens.

### Pitfall 5: Auto-title generation on first message
**What goes wrong:** Title is set to a truncated version of the first user message, but the update never fires.
**Why it happens:** The title is set conditionally only when the conversation has no title yet. Race condition if client retries the request.
**How to avoid:** Use `WHERE title IS NULL` in the UPDATE to make the title set idempotent.

### Pitfall 6: Conversation list flicker on `useInfiniteQuery`
**What goes wrong:** List flashes empty on first render before data loads.
**Why it happens:** Default TanStack Query behavior shows `isLoading: true` on mount.
**How to avoid:** Use `placeholderData: keepPreviousData` or show a skeleton list while loading.

---

## Code Examples

### Verified: Route factory pattern
```typescript
// Source: server/src/routes/activity.ts (exists in codebase)
export function activityRoutes(db: Db): Router {
  const router = Router();
  const svc = activityService(db);
  router.get("/companies/:companyId/activity", async (req, res) => {
    assertBoard(req);
    assertCompanyAccess(req, req.params.companyId!);
    const result = await svc.list(req.params.companyId!);
    res.json(result);
  });
  return router;
}
```

### Verified: react-markdown + rehype plugin composition
```typescript
// Source: MarkdownBody.tsx (extended pattern)
import rehypeHighlight from "rehype-highlight";
<Markdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypeHighlight]}
  components={components}
>
  {content}
</Markdown>
```

### Verified: Infinite scroll with TanStack Query v5
```typescript
// Source: TanStack Query v5 docs — useInfiniteQuery API
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["conversations", companyId],
  queryFn: ({ pageParam }) => chatApi.listConversations(companyId, { cursor: pageParam }),
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (lastPage) =>
    lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
});
```

### Verified: CSS transition pattern for drawer (from Layout.tsx)
```tsx
// Same transition as sidebar — proven in production
<div
  className="overflow-hidden transition-[width] duration-100 ease-out"
  style={{ width: chatOpen ? 380 : 0 }}
>
  {/* panel content — will be hidden via width:0 overflow:hidden */}
</div>
```

---

## State of the Art

| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| `react-syntax-highlighter` (Prism) | `rehype-highlight` (hljs) via rehype plugins | Smaller bundle, cleaner react-markdown integration, native theme CSS |
| Custom infinite scroll with IntersectionObserver | `useInfiniteQuery` with cursor | Less code, built-in refetch/stale handling |
| CSS modules for code block themes | CSS custom properties per `.dark` class | Theme switch without JS style injection |

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|-------------|-----------|---------|----------|
| PostgreSQL (embedded) | DB persistence | Yes (embedded-postgres) | 17-alpine | — |
| pnpm | Package install | Yes | 9.15.4 | — |
| Node.js | Runtime | Yes | v25.8.2 | — |
| `rehype-highlight` | Syntax highlighting | Not installed (needs `pnpm add`) | 7.0.2 | Fallback: unstyled code blocks (degrade gracefully) |

**Missing dependencies with no fallback:**
- None that block execution

**Missing dependencies that need install:**
- `rehype-highlight` — install before implementing `ChatMarkdownMessage.tsx`

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 3.x |
| Config file | `server/vitest.config.ts` (server), `vitest.config.ts` (root, multi-project) |
| Quick run command | `pnpm vitest run server/src/__tests__/chat-service.test.ts` |
| Full suite command | `pnpm test:run` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| HIST-01 | Conversations and messages persisted to DB | Unit (service) | `pnpm vitest run server/src/__tests__/chat-service.test.ts` | ❌ Wave 0 |
| HIST-01 | POST conversation creates DB row; GET returns it | Integration (route) | `pnpm vitest run server/src/__tests__/chat-routes.test.ts` | ❌ Wave 0 |
| CHAT-04 | List conversations returns all for company, sorted by updatedAt | Unit (service) | above | ❌ Wave 0 |
| CHAT-05 | First message auto-sets title on conversation | Unit (service) | above | ❌ Wave 0 |
| CHAT-06 | Soft-delete / archive / pin set correct timestamps | Unit (service) | above | ❌ Wave 0 |
| CHAT-02/03 | MarkdownBody renders code blocks with syntax highlight | Component (UI) | `pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx` | ❌ Wave 0 |
| INPUT-07 | Enter sends, Shift+Enter inserts newline, Escape clears | Component (UI) | `pnpm vitest run ui/src/components/ChatInput.test.tsx` | ❌ Wave 0 |
| THEME-01/02 | Theme CSS variables apply to chat panel and code blocks | Manual visual | — | Manual only |
| HIST-03 | Infinite scroll loads next page on scroll to bottom | Integration (UI) | — | Manual only — requires browser scroll |

### Sampling Rate
- **Per task commit:** `pnpm vitest run server/src/__tests__/chat-service.test.ts`
- **Per wave merge:** `pnpm test:run`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `server/src/__tests__/chat-service.test.ts` — covers HIST-01, CHAT-04, CHAT-05, CHAT-06
- [ ] `server/src/__tests__/chat-routes.test.ts` — covers route-level integration (POST conversation, GET list, POST message)
- [ ] `ui/src/components/ChatMarkdownMessage.test.tsx` — covers CHAT-02/03 (code block render + copy button)
- [ ] `ui/src/components/ChatInput.test.tsx` — covers INPUT-07 keyboard shortcuts

---

## Open Questions

1. **Chat panel vs PropertiesPanel coexistence**
   - What we know: Both are right-side panels in the same flex row in `Layout.tsx`
   - What's unclear: Should they coexist (both visible side-by-side) or should opening chat close the properties panel?
   - Recommendation: Close `PropertiesPanel` when chat opens (simpler, avoids cramped UI at default 1280px width)

2. **`chat_conversations.agentId` — required or optional at creation time?**
   - What we know: Phase 22 adds the agent selector mid-conversation. Phase 21 has no streaming.
   - What's unclear: Do we need an agent association before streaming exists?
   - Recommendation: Make `agentId` nullable. Allow conversations to be created without a linked agent. The column is available for Phase 22 to use.

3. **Auto-generated title: server-side or client-side?**
   - What we know: Client sends the first message; the title should derive from that message's first N characters.
   - Recommendation: Server-side, in `chatService.addMessage()` — if `conversation.title IS NULL` AND this is the first message, set `title = truncate(content, 60)`. Avoids a client round-trip.

---

## Sources

### Primary (HIGH confidence)
- `ui/src/components/MarkdownBody.tsx` — existing markdown component confirming `react-markdown` + `remark-gfm` usage
- `ui/src/context/PanelContext.tsx` — panel open/close localStorage pattern
- `ui/src/components/Layout.tsx` — layout structure, flex row, CSS transition pattern
- `packages/db/src/schema/documents.ts` — Drizzle schema reference pattern
- `server/src/routes/activity.ts` — route factory pattern
- `server/src/services/live-events.ts` — service file pattern
- `packages/db/src/client.ts` — database client (PostgreSQL, not libSQL)
- `ui/src/context/ThemeContext.tsx` — Theme type, THEME_META, `useTheme()`
- `ui/src/index.css` — CSS variable definitions for all three themes
- `ui/package.json` — confirmed `react-markdown@^10.1.0`, `remark-gfm@^4.0.1` already installed

### Secondary (MEDIUM confidence)
- `npm view rehype-highlight version` → 7.0.2 (verified at research time, 2026-04-01)
- TanStack Query v5 `useInfiniteQuery` API (CLAUDE.md confirms `@tanstack/react-query ^5.x`)

### Tertiary (LOW confidence)
- highlight.js Catppuccin/Tokyo Night theme availability — assumed based on hljs 11.x changelog; verify exact CSS path at install time

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all existing dependencies verified in source; only one new package (`rehype-highlight`) needed
- Architecture: HIGH — patterns confirmed by reading existing service/route/context files; direct analogy to existing `documents` domain
- Pitfalls: MEDIUM — identified from code inspection; cascade delete and updatedAt bump are logic traps not yet observable in running code
- Theme integration: HIGH — `THEME_META`, ThemeContext, and CSS variables fully examined

**Research date:** 2026-04-01
**Valid until:** 2026-05-01 (stable ecosystem; `react-markdown` and TanStack Query are very stable)
