Planning artifacts (milestones v1.0-v1.2.1, v1.3 queue, PROJECT.md, STATE.md, config) now live alongside the code they describe. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
557 lines
29 KiB
Markdown
557 lines
29 KiB
Markdown
# 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)
|