[nexus] docs(21-chat-foundation): create phase plan

4 plans across 3 waves for Chat Foundation phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-01 12:52:49 +02:00
parent 3f14268682
commit af211e6a39
5 changed files with 1684 additions and 2 deletions

View file

@ -31,7 +31,12 @@
3. Agent messages render with full markdown: code blocks with syntax highlighting and a copy button, tables, lists, headings, links, and inline images
4. Conversations and all messages are stored in libSQL and survive a server restart
5. The chat interface applies Catppuccin Mocha, Tokyo Night, and Catppuccin Latte themes correctly; code block highlighting matches the active theme
**Plans**: TBD
**Plans:** 4 plans
Plans:
- [ ] 21-01-PLAN.md — DB schema, shared types, service layer, and REST API for conversations and messages
- [ ] 21-02-PLAN.md — ChatMarkdownMessage with syntax highlighting/copy button, ChatInput with auto-resize/keyboard shortcuts, theme CSS
- [ ] 21-03-PLAN.md — Chat API client, panel context, hooks, ChatPanel/ConversationList/MessageList, Layout integration
- [ ] 21-04-PLAN.md — Full test suite verification and visual/functional checkpoint
**UI hint**: yes
### Phase 22: Agent Streaming
@ -180,7 +185,7 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans.
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 21. Chat Foundation | v1.3 | 0/? | Not started | - |
| 21. Chat Foundation | v1.3 | 0/4 | Planning complete | - |
| 22. Agent Streaming | v1.3 | 0/? | Not started | - |
| 23. Brainstormer Flow | v1.3 | 0/? | Not started | - |
| 24. Search, History & Branching | v1.3 | 0/? | Not started | - |

View file

@ -0,0 +1,512 @@
---
phase: 21-chat-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- packages/db/src/schema/chat_conversations.ts
- packages/db/src/schema/chat_messages.ts
- packages/db/src/schema/index.ts
- packages/shared/src/types/chat.ts
- packages/shared/src/validators/chat.ts
- server/src/services/chat.ts
- server/src/routes/chat.ts
- server/src/routes/index.ts
- server/src/app.ts
- server/src/__tests__/chat-service.test.ts
- server/src/__tests__/chat-routes.test.ts
autonomous: true
requirements:
- HIST-01
- HIST-05
- HIST-06
- CHAT-04
- CHAT-05
- CHAT-06
must_haves:
truths:
- "Conversations and messages are stored in PostgreSQL and survive server restarts"
- "Multiple conversations exist per company, listed sorted by updatedAt DESC"
- "First message on a conversation auto-generates a title from first 60 characters"
- "Conversations can be soft-deleted, archived, and pinned via timestamp columns"
- "Conversations are accessible from any device via the REST API"
artifacts:
- path: "packages/db/src/schema/chat_conversations.ts"
provides: "chat_conversations Drizzle table definition"
contains: "export const chatConversations"
- path: "packages/db/src/schema/chat_messages.ts"
provides: "chat_messages Drizzle table definition with cascade delete"
contains: "onDelete: \"cascade\""
- path: "packages/shared/src/types/chat.ts"
provides: "ChatConversation and ChatMessage TypeScript interfaces"
exports: ["ChatConversation", "ChatMessage"]
- path: "packages/shared/src/validators/chat.ts"
provides: "Zod schemas for create/update conversation and message"
exports: ["createConversationSchema", "createMessageSchema", "updateConversationSchema"]
- path: "server/src/services/chat.ts"
provides: "chatService factory with CRUD operations"
exports: ["chatService"]
- path: "server/src/routes/chat.ts"
provides: "chatRoutes factory mounting conversation and message endpoints"
exports: ["chatRoutes"]
- path: "server/src/__tests__/chat-service.test.ts"
provides: "Service-level unit tests"
min_lines: 80
- path: "server/src/__tests__/chat-routes.test.ts"
provides: "Route-level integration tests"
min_lines: 60
key_links:
- from: "server/src/routes/chat.ts"
to: "server/src/services/chat.ts"
via: "chatService(db) factory call"
pattern: "chatService\\(db\\)"
- from: "server/src/app.ts"
to: "server/src/routes/chat.ts"
via: "api.use(chatRoutes(db))"
pattern: "chatRoutes\\(db\\)"
- from: "packages/db/src/schema/index.ts"
to: "packages/db/src/schema/chat_conversations.ts"
via: "re-export"
pattern: "export.*chatConversations.*chat_conversations"
---
<objective>
Create the database schema, shared types, service layer, and REST API for chat conversations and messages.
Purpose: Establishes the persistence and API foundation that all UI components in subsequent plans depend on. Without this, no conversation can be created, stored, or retrieved.
Output: Two new Drizzle schema files, a migration, shared types + validators, service + route factories, and automated tests.
</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/21-chat-foundation/21-RESEARCH.md
<interfaces>
<!-- Existing patterns the executor must follow exactly -->
From packages/db/src/schema/documents.ts (reference pattern for new schema):
```typescript
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
// ... columns
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
}),
);
```
From server/src/routes/activity.ts (reference pattern for route factory):
```typescript
export function activityRoutes(db: Db) {
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(filters);
res.json(result);
});
return router;
}
```
From server/src/__tests__/activity-routes.test.ts (reference test pattern):
```typescript
const mockActivityService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board", userId: "user-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false,
};
next();
});
app.use("/api", activityRoutes({} as any));
app.use(errorHandler);
return app;
}
```
From server/src/middleware/validate.ts:
```typescript
export function validate(schema: ZodSchema) {
return (req: Request, _res: Response, next: NextFunction) => {
req.body = schema.parse(req.body);
next();
};
}
```
From server/src/app.ts (route mounting — line 158):
```typescript
api.use(activityRoutes(db));
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: DB schema, shared types, validators, and service with tests</name>
<files>
packages/db/src/schema/chat_conversations.ts,
packages/db/src/schema/chat_messages.ts,
packages/db/src/schema/index.ts,
packages/shared/src/types/chat.ts,
packages/shared/src/validators/chat.ts,
server/src/services/chat.ts,
server/src/__tests__/chat-service.test.ts
</files>
<read_first>
packages/db/src/schema/documents.ts,
packages/db/src/schema/index.ts,
packages/db/src/schema/companies.ts,
packages/db/src/schema/agents.ts,
packages/shared/src/types/company.ts,
packages/shared/src/validators/company.ts,
server/src/services/documents.ts,
server/src/__tests__/activity-routes.test.ts
</read_first>
<behavior>
- Test: chatService(db).createConversation({ companyId, title }) inserts a row and returns it with id, companyId, title, createdAt, updatedAt
- Test: chatService(db).listConversations(companyId, {}) returns items sorted by updatedAt DESC, excludes soft-deleted rows (deletedAt IS NOT NULL)
- Test: chatService(db).listConversations with cursor returns only rows older than cursor
- Test: chatService(db).listConversations returns hasMore=true when more rows exist beyond limit
- Test: chatService(db).addMessage({ conversationId, role, content }) inserts message and bumps conversation.updatedAt
- Test: chatService(db).addMessage on a conversation with title=null sets title to first 60 chars of content
- Test: chatService(db).addMessage on a conversation with existing title does NOT overwrite title
- Test: chatService(db).softDeleteConversation sets deletedAt timestamp
- Test: chatService(db).archiveConversation sets archivedAt timestamp
- Test: chatService(db).pinConversation sets pinnedAt timestamp
- Test: chatService(db).unpinConversation clears pinnedAt to null
- Test: chatService(db).updateConversation({ title }) updates title
</behavior>
<action>
1. Create `packages/db/src/schema/chat_conversations.ts`:
- Table name: `chat_conversations`
- Columns: `id` (uuid PK defaultRandom), `companyId` (uuid FK to companies.id, NOT NULL), `title` (text, nullable), `agentId` (uuid FK to agents.id onDelete "set null", nullable), `pinnedAt` (timestamp with tz, nullable), `archivedAt` (timestamp with tz, nullable), `deletedAt` (timestamp with tz, nullable), `createdAt` (timestamp with tz, NOT NULL, defaultNow), `updatedAt` (timestamp with tz, NOT NULL, defaultNow)
- Indexes: `chat_conversations_company_updated_idx` on (companyId, updatedAt), `chat_conversations_company_deleted_idx` on (companyId, deletedAt)
- Export: `export const chatConversations`
2. Create `packages/db/src/schema/chat_messages.ts`:
- Table name: `chat_messages`
- Columns: `id` (uuid PK defaultRandom), `conversationId` (uuid FK to chatConversations.id onDelete "cascade", NOT NULL), `role` (text NOT NULL — values: "user" | "assistant" | "system"), `content` (text NOT NULL), `agentId` (uuid, nullable — which agent produced this), `createdAt` (timestamp with tz, NOT NULL, defaultNow)
- Index: `chat_messages_conversation_created_idx` on (conversationId, createdAt)
- Export: `export const chatMessages`
3. Add to `packages/db/src/schema/index.ts` — append two lines:
```
export { chatConversations } from "./chat_conversations.js";
export { chatMessages } from "./chat_messages.js";
```
4. Run `pnpm db:generate` to generate migration SQL. Verify the generated SQL contains:
- `CREATE TABLE "chat_conversations"` with all columns
- `CREATE TABLE "chat_messages"` with `ON DELETE CASCADE` on conversation_id FK
- Both index `CREATE INDEX` statements
5. Create `packages/shared/src/types/chat.ts`:
```typescript
export interface ChatConversation {
id: string;
companyId: string;
title: string | null;
agentId: string | null;
pinnedAt: string | null;
archivedAt: string | null;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
role: "user" | "assistant" | "system";
content: string;
agentId: string | null;
createdAt: string;
}
export interface ChatConversationListResponse {
items: ChatConversation[];
hasMore: boolean;
}
```
6. Create `packages/shared/src/validators/chat.ts`:
```typescript
import { z } from "zod";
export const createConversationSchema = z.object({
title: z.string().max(200).optional(),
});
export const updateConversationSchema = z.object({
title: z.string().max(200).optional(),
});
export const createMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string().min(1),
agentId: z.string().uuid().optional().nullable(),
});
```
7. Create `server/src/services/chat.ts` following the `documentService` factory pattern:
```typescript
export function chatService(db: Db) {
return {
async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) { ... },
async createConversation(companyId: string, data: { title?: string }) { ... },
async getConversation(id: string) { ... },
async updateConversation(id: string, data: { title?: string }) { ... },
async softDeleteConversation(id: string) { ... },
async archiveConversation(id: string) { ... },
async unarchiveConversation(id: string) { ... },
async pinConversation(id: string) { ... },
async unpinConversation(id: string) { ... },
async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) { ... },
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) { ... },
};
}
```
Key implementation details:
- `listConversations`: filter `WHERE companyId = $1 AND deletedAt IS NULL`, order by `updatedAt DESC`, cursor-based pagination with `updatedAt < cursor`, limit defaults to 30 (max 100), return `{ items, hasMore }` where hasMore is `rows.length > limit` and items is `rows.slice(0, limit)`
- `addMessage`: after inserting the message, run `UPDATE chat_conversations SET updated_at = now() WHERE id = $conversationId`. Also, if `conversation.title IS NULL`, set `title = content.slice(0, 60)` using `WHERE id = $conversationId AND title IS NULL` for idempotency
- `softDeleteConversation`: `UPDATE chat_conversations SET deleted_at = now() WHERE id = $id`
- `archiveConversation`: `UPDATE SET archived_at = now()`
- `pinConversation`: `UPDATE SET pinned_at = now()`
- `unpinConversation`: `UPDATE SET pinned_at = null`
8. Create `server/src/__tests__/chat-service.test.ts` using the `vi.mock` pattern from `activity-routes.test.ts`. Mock `@paperclipai/db` to provide a mock `db` object with chainable `.select().from().where().orderBy().limit()` and `.insert().values().returning()` and `.update().set().where()` methods. Test all behaviors listed in the `<behavior>` block above.
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-service.test.ts</automated>
</verify>
<acceptance_criteria>
- packages/db/src/schema/chat_conversations.ts contains `export const chatConversations = pgTable("chat_conversations"`
- packages/db/src/schema/chat_conversations.ts contains `pinnedAt: timestamp("pinned_at"` and `archivedAt: timestamp("archived_at"` and `deletedAt: timestamp("deleted_at"`
- packages/db/src/schema/chat_messages.ts contains `onDelete: "cascade"`
- packages/db/src/schema/chat_messages.ts contains `role: text("role").notNull()`
- packages/db/src/schema/index.ts contains `export { chatConversations } from "./chat_conversations.js"`
- packages/db/src/schema/index.ts contains `export { chatMessages } from "./chat_messages.js"`
- packages/shared/src/types/chat.ts contains `export interface ChatConversation`
- packages/shared/src/types/chat.ts contains `export interface ChatMessage`
- packages/shared/src/validators/chat.ts contains `export const createConversationSchema`
- packages/shared/src/validators/chat.ts contains `export const createMessageSchema`
- server/src/services/chat.ts contains `export function chatService(db: Db)`
- server/src/services/chat.ts contains `async addMessage(`
- server/src/services/chat.ts contains `title IS NULL` or `isNull(chatConversations.title)` for idempotent title set
- server/src/__tests__/chat-service.test.ts exits 0
</acceptance_criteria>
<done>All schema files, types, validators, and service exist. Service test suite passes with coverage of list, create, add message (with auto-title), soft-delete, archive, pin/unpin.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: REST API routes and route tests</name>
<files>
server/src/routes/chat.ts,
server/src/routes/index.ts,
server/src/app.ts,
server/src/__tests__/chat-routes.test.ts
</files>
<read_first>
server/src/routes/activity.ts,
server/src/routes/index.ts,
server/src/routes/authz.ts,
server/src/app.ts,
server/src/__tests__/activity-routes.test.ts,
server/src/services/chat.ts,
packages/shared/src/validators/chat.ts
</read_first>
<behavior>
- Test: GET /api/companies/:companyId/conversations returns 200 with { items: [], hasMore: false } when empty
- Test: POST /api/companies/:companyId/conversations returns 201 with the created conversation object
- Test: GET /api/conversations/:id returns 200 with conversation object
- Test: PATCH /api/conversations/:id with { title: "new title" } returns 200 with updated conversation
- Test: DELETE /api/conversations/:id returns 204
- Test: POST /api/conversations/:id/archive returns 200
- Test: POST /api/conversations/:id/unarchive returns 200
- Test: POST /api/conversations/:id/pin returns 200
- Test: POST /api/conversations/:id/unpin returns 200
- Test: GET /api/conversations/:id/messages returns 200 with { items: [], hasMore: false }
- Test: POST /api/conversations/:id/messages with { role: "user", content: "hello" } returns 201
</behavior>
<action>
1. Create `server/src/routes/chat.ts`:
```typescript
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.js";
import { validate } from "../middleware/validate.js";
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
export function chatRoutes(db: Db) {
const router = Router();
const svc = chatService(db);
// GET /api/companies/:companyId/conversations
router.get("/companies/:companyId/conversations", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const cursor = req.query.cursor as string | undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const result = await svc.listConversations(companyId, { cursor, limit });
res.json(result);
});
// POST /api/companies/:companyId/conversations
router.post("/companies/:companyId/conversations", validate(createConversationSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const conversation = await svc.createConversation(companyId, req.body);
res.status(201).json(conversation);
});
// GET /api/conversations/:id
router.get("/conversations/:id", async (req, res) => {
assertBoard(req);
const conversation = await svc.getConversation(req.params.id as string);
if (!conversation) { res.status(404).json({ error: "Not found" }); return; }
res.json(conversation);
});
// PATCH /api/conversations/:id
router.patch("/conversations/:id", validate(updateConversationSchema), async (req, res) => {
assertBoard(req);
const conversation = await svc.updateConversation(req.params.id as string, req.body);
res.json(conversation);
});
// DELETE /api/conversations/:id
router.delete("/conversations/:id", async (req, res) => {
assertBoard(req);
await svc.softDeleteConversation(req.params.id as string);
res.status(204).end();
});
// POST /api/conversations/:id/archive
router.post("/conversations/:id/archive", async (req, res) => {
assertBoard(req);
const result = await svc.archiveConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/unarchive
router.post("/conversations/:id/unarchive", async (req, res) => {
assertBoard(req);
const result = await svc.unarchiveConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/pin
router.post("/conversations/:id/pin", async (req, res) => {
assertBoard(req);
const result = await svc.pinConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/unpin
router.post("/conversations/:id/unpin", async (req, res) => {
assertBoard(req);
const result = await svc.unpinConversation(req.params.id as string);
res.json(result);
});
// GET /api/conversations/:id/messages
router.get("/conversations/:id/messages", async (req, res) => {
assertBoard(req);
const cursor = req.query.cursor as string | undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const result = await svc.listMessages(req.params.id as string, { cursor, limit });
res.json(result);
});
// POST /api/conversations/:id/messages
router.post("/conversations/:id/messages", validate(createMessageSchema), async (req, res) => {
assertBoard(req);
const message = await svc.addMessage(req.params.id as string, req.body);
res.status(201).json(message);
});
return router;
}
```
2. Add to `server/src/routes/index.ts`:
```
export { chatRoutes } from "./chat.js";
```
3. In `server/src/app.ts`, add import `import { chatRoutes } from "./routes/chat.js";` near line 27 (after activityRoutes import), and add `api.use(chatRoutes(db));` after line 158 (after `api.use(activityRoutes(db));`).
4. Create `server/src/__tests__/chat-routes.test.ts` following the exact `activity-routes.test.ts` mock pattern:
- Use `vi.hoisted` to create `mockChatService` with all methods as `vi.fn()`
- `vi.mock("../services/chat.js", () => ({ chatService: () => mockChatService }))`
- `createApp()` function that sets up express with JSON parsing, actor middleware (type: "board", companyIds: ["company-1"]), mounts `chatRoutes({} as any)` under `/api`, and adds `errorHandler`
- Test all behaviors listed in `<behavior>` block
- Also mock `@paperclipai/shared` validators if needed, or let them pass through (they are pure Zod schemas that work without mocking)
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-routes.test.ts</automated>
</verify>
<acceptance_criteria>
- server/src/routes/chat.ts contains `export function chatRoutes(db: Db)`
- server/src/routes/chat.ts contains `router.get("/companies/:companyId/conversations"`
- server/src/routes/chat.ts contains `router.post("/conversations/:id/messages"`
- server/src/routes/chat.ts contains `assertBoard(req)` on every mutating route
- server/src/routes/chat.ts contains `assertCompanyAccess(req, companyId)` on the list endpoint
- server/src/routes/index.ts contains `export { chatRoutes } from "./chat.js"`
- server/src/app.ts contains `import { chatRoutes }` and `chatRoutes(db)`
- server/src/__tests__/chat-routes.test.ts exits 0
</acceptance_criteria>
<done>All chat REST endpoints exist and respond with correct status codes. Route test suite passes. Routes are mounted in app.ts and exported from routes/index.ts.</done>
</task>
</tasks>
<verification>
- `pnpm vitest run server/src/__tests__/chat-service.test.ts` passes
- `pnpm vitest run server/src/__tests__/chat-routes.test.ts` passes
- `pnpm db:generate` produces migration SQL with both tables and cascade FK
- `grep -r "chatConversations\|chatMessages" packages/db/src/schema/index.ts` shows both exports
</verification>
<success_criteria>
- Two new DB tables (chat_conversations, chat_messages) with correct columns, indexes, and cascade FK
- Shared types and Zod validators for all create/update operations
- Service layer with full CRUD + auto-title on first message + updatedAt bump
- REST API with 11 endpoints mounted in app.ts
- All automated tests green
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,483 @@
---
phase: 21-chat-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- ui/src/components/ChatMarkdownMessage.tsx
- ui/src/components/ChatMarkdownMessage.test.tsx
- ui/src/components/ChatInput.tsx
- ui/src/components/ChatInput.test.tsx
- ui/src/index.css
autonomous: true
requirements:
- CHAT-02
- CHAT-03
- INPUT-01
- INPUT-07
- THEME-01
- THEME-02
must_haves:
truths:
- "Agent messages render with full markdown: code blocks with syntax highlighting, tables, lists, headings, links, inline images"
- "Code blocks have a one-click copy button and a language label"
- "Code block syntax highlighting colors match the active theme (Catppuccin Mocha, Tokyo Night, Catppuccin Latte)"
- "Chat input auto-resizes from 1 line up to 6 lines then scrolls internally"
- "Enter sends, Shift+Enter inserts newline, Escape clears input or closes panel"
- "Chat interface respects the Nexus theme system via CSS variables"
artifacts:
- path: "ui/src/components/ChatMarkdownMessage.tsx"
provides: "Markdown message renderer with syntax highlighting and copy button"
contains: "rehypeHighlight"
- path: "ui/src/components/ChatInput.tsx"
provides: "Auto-resize textarea with keyboard shortcuts"
contains: "onKeyDown"
- path: "ui/src/components/ChatMarkdownMessage.test.tsx"
provides: "Tests for markdown rendering, code block copy button"
min_lines: 30
- path: "ui/src/components/ChatInput.test.tsx"
provides: "Tests for keyboard shortcuts"
min_lines: 30
key_links:
- from: "ui/src/components/ChatMarkdownMessage.tsx"
to: "ui/src/components/MarkdownBody.tsx"
via: "extends MarkdownBody pattern with rehypeHighlight"
pattern: "rehypeHighlight"
- from: "ui/src/index.css"
to: "highlight.js themes"
via: "CSS overrides for .hljs per theme class"
pattern: "\\.hljs"
---
<objective>
Build the two core presentational components for chat: the markdown message renderer (with syntax highlighting and copy button) and the auto-resize text input (with keyboard shortcuts). Also install rehype-highlight and add theme-aware highlight.js CSS.
Purpose: These components are self-contained and have no dependency on the backend API. Building them in Wave 1 alongside Plan 01 maximizes parallelism.
Output: Two tested React components ready to be composed into the ChatPanel 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/phases/21-chat-foundation/21-RESEARCH.md
@.planning/phases/21-chat-foundation/21-UI-SPEC.md
<interfaces>
<!-- Existing components and patterns the executor must reference -->
From ui/src/components/MarkdownBody.tsx:
```typescript
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { useTheme, THEME_META } from "../context/ThemeContext";
interface MarkdownBodyProps {
children: string;
className?: string;
resolveImageSrc?: (src: string) => string | null;
}
// Uses: <Markdown remarkPlugins={[remarkGfm]} components={components}>{content}</Markdown>
```
From ui/src/context/ThemeContext.tsx:
```typescript
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }>;
// Theme classes on <html>: .dark (both dark themes), .theme-tokyo-night (tokyo-night only)
// No class for catppuccin-mocha (it's the default dark), no class for catppuccin-latte (it's light)
```
From ui/src/components/ui/textarea.tsx (shadcn Textarea):
```typescript
// Standard shadcn Textarea component, wraps <textarea> with cn() classNames
```
From ui/src/lib/utils.ts:
```typescript
export function cn(...inputs: ClassValue[]) { ... }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install rehype-highlight and add theme-aware highlight.js CSS to index.css</name>
<files>
ui/src/index.css
</files>
<read_first>
ui/package.json,
ui/src/index.css,
ui/src/context/ThemeContext.tsx
</read_first>
<action>
1. Run: `pnpm --filter @paperclipai/ui add rehype-highlight`
This pulls in `rehype-highlight` (7.0.2) and `highlight.js` as a transitive dependency.
2. Verify installation: `ls node_modules/highlight.js/styles/base16/` should contain catppuccin theme files. `ls node_modules/highlight.js/styles/` should contain `tokyo-night-dark.css`.
3. Add theme-aware highlight.js CSS overrides to `ui/src/index.css`. Append AFTER the existing theme CSS variable blocks (after the last `}` of theme definitions, before any Tailwind utility layers). Do NOT import three separate CSS files — instead, define a single CSS block that maps `.hljs` variables per theme class:
```css
/* ── highlight.js theme-aware overrides ───────────────────────── */
@import "highlight.js/styles/base16/catppuccin-mocha.css" layer(hljs);
/*
* The base import gives us catppuccin-mocha as default.
* Override for tokyo-night and catppuccin-latte via specificity.
*/
.theme-tokyo-night .hljs {
background: var(--card);
color: #a9b1d6;
}
.theme-tokyo-night .hljs-keyword { color: #bb9af7; }
.theme-tokyo-night .hljs-string { color: #9ece6a; }
.theme-tokyo-night .hljs-number { color: #ff9e64; }
.theme-tokyo-night .hljs-comment { color: #565f89; }
.theme-tokyo-night .hljs-function,
.theme-tokyo-night .hljs-title { color: #7aa2f7; }
.theme-tokyo-night .hljs-built_in { color: #e0af68; }
.theme-tokyo-night .hljs-type { color: #2ac3de; }
.theme-tokyo-night .hljs-attr { color: #73daca; }
.theme-tokyo-night .hljs-literal { color: #ff9e64; }
.theme-tokyo-night .hljs-selector-class { color: #7aa2f7; }
:root:not(.dark) .hljs {
background: var(--card);
color: #4c4f69;
}
:root:not(.dark) .hljs-keyword { color: #8839ef; }
:root:not(.dark) .hljs-string { color: #40a02b; }
:root:not(.dark) .hljs-number { color: #fe640b; }
:root:not(.dark) .hljs-comment { color: #9ca0b0; }
:root:not(.dark) .hljs-function,
:root:not(.dark) .hljs-title { color: #1e66f5; }
:root:not(.dark) .hljs-built_in { color: #df8e1d; }
:root:not(.dark) .hljs-type { color: #179299; }
:root:not(.dark) .hljs-attr { color: #179299; }
:root:not(.dark) .hljs-literal { color: #fe640b; }
:root:not(.dark) .hljs-selector-class { color: #1e66f5; }
```
This approach: imports catppuccin-mocha as the base layer (matches default dark theme), overrides for tokyo-night via `.theme-tokyo-night` class (which ThemeContext applies to `<html>`), and overrides for catppuccin-latte via `:root:not(.dark)` (light mode). Uses `var(--card)` for code block background so it integrates with the theme system. The `layer(hljs)` import keeps specificity manageable.
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "rehype-highlight" ui/package.json && grep -c "\.hljs" ui/src/index.css</automated>
</verify>
<acceptance_criteria>
- ui/package.json contains "rehype-highlight" in dependencies
- ui/src/index.css contains `.theme-tokyo-night .hljs`
- ui/src/index.css contains `:root:not(.dark) .hljs`
- ui/src/index.css contains `@import "highlight.js/styles/base16/catppuccin-mocha.css"`
- ui/src/index.css does NOT contain three separate `@import` for highlight.js (only one base import)
</acceptance_criteria>
<done>rehype-highlight installed. Theme-aware highlight.js CSS added to index.css covering all three themes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: ChatMarkdownMessage component with syntax highlighting and copy button</name>
<files>
ui/src/components/ChatMarkdownMessage.tsx,
ui/src/components/ChatMarkdownMessage.test.tsx
</files>
<read_first>
ui/src/components/MarkdownBody.tsx,
ui/src/context/ThemeContext.tsx,
ui/src/lib/utils.ts,
ui/src/index.css
</read_first>
<behavior>
- Test: renders markdown with headings, bold, italic, links, lists, tables
- Test: renders code blocks with `language-` class from highlight.js
- Test: code block container has a copy button with aria-label="Copy code"
- Test: code block shows language label when language is specified (e.g. "typescript")
- Test: inline code renders without copy button
- Test: renders inline images with img tag
</behavior>
<action>
Create `ui/src/components/ChatMarkdownMessage.tsx`:
```typescript
import { useCallback, useState, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { Copy, Check } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
interface ChatMarkdownMessageProps {
content: string;
className?: string;
}
function extractText(node: ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractText).join("");
if (node && typeof node === "object" && "props" in node) {
return extractText((node as any).props.children);
}
return "";
}
function CodeBlock({ children, className, ...props }: { children?: ReactNode; className?: string }) {
const [copied, setCopied] = useState(false);
const language = className?.replace(/^language-/, "") ?? null;
const codeText = extractText(children);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [codeText]);
return (
<div className="relative bg-card border border-border my-2">
{language && (
<span className="absolute top-2 left-3 text-xs text-muted-foreground select-none">
{language}
</span>
)}
<Button
variant="ghost"
size="icon"
className="absolute top-1.5 right-1.5 h-7 w-7"
onClick={handleCopy}
aria-label="Copy code"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
<pre className={cn("overflow-x-auto p-4 pt-8 text-sm", className)} {...props}>
<code className={className}>{children}</code>
</pre>
</div>
);
}
const components: Partial<Components> = {
pre({ children, ...props }) {
// Check if child is a <code> element with language class (code block)
if (children && typeof children === "object" && "props" in (children as any)) {
const childProps = (children as any).props;
const childClassName = childProps?.className ?? "";
return (
<CodeBlock className={childClassName} {...props}>
{childProps?.children}
</CodeBlock>
);
}
return <pre {...props}>{children}</pre>;
},
};
export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
return (
<div className={cn("prose prose-sm max-w-none dark:prose-invert", className)}>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={components}
>
{content}
</Markdown>
</div>
);
}
```
Create `ui/src/components/ChatMarkdownMessage.test.tsx`:
- Import `render, screen` from `@testing-library/react`
- Test rendering markdown headings: `render(<ChatMarkdownMessage content="# Hello" />)`, expect `screen.getByRole("heading", { level: 1 })` to have text "Hello"
- Test code block: `render(<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />)`, expect `screen.getByLabelText("Copy code")` to exist, expect language label "typescript" to be in the document
- Test inline code does NOT have copy button: render with `\`inline\`` and verify no "Copy code" button
- Test tables, lists, links render as expected HTML elements
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx</automated>
</verify>
<acceptance_criteria>
- ui/src/components/ChatMarkdownMessage.tsx contains `import rehypeHighlight from "rehype-highlight"`
- ui/src/components/ChatMarkdownMessage.tsx contains `rehypePlugins={[rehypeHighlight]}`
- ui/src/components/ChatMarkdownMessage.tsx contains `aria-label="Copy code"`
- ui/src/components/ChatMarkdownMessage.tsx contains `navigator.clipboard.writeText`
- ui/src/components/ChatMarkdownMessage.tsx exports `ChatMarkdownMessage`
- ui/src/components/ChatMarkdownMessage.test.tsx exits 0
</acceptance_criteria>
<done>ChatMarkdownMessage renders full markdown with syntax-highlighted code blocks, language labels, and copy buttons. Test suite green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: ChatInput component with auto-resize and keyboard shortcuts</name>
<files>
ui/src/components/ChatInput.tsx,
ui/src/components/ChatInput.test.tsx
</files>
<read_first>
ui/src/components/ui/textarea.tsx,
ui/src/lib/utils.ts,
ui/src/components/MarkdownBody.tsx
</read_first>
<behavior>
- Test: Enter key (without Shift) calls onSend with current value and clears input
- Test: Shift+Enter inserts a newline (does not call onSend)
- Test: Escape clears input when input has content
- Test: Escape calls onClose when input is empty
- Test: Send button is disabled when input is empty
- Test: Send button has aria-label="Send message"
- Test: textarea has aria-label="Message input"
- Test: input is disabled and send shows loader when isSubmitting=true
</behavior>
<action>
Create `ui/src/components/ChatInput.tsx`:
```typescript
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
import { Loader2, Send } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
interface ChatInputProps {
onSend: (content: string) => void;
onClose?: () => void;
isSubmitting?: boolean;
className?: string;
}
export function ChatInput({ onSend, onClose, isSubmitting = false, className }: ChatInputProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 160) + "px";
}, []);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isSubmitting) return;
onSend(trimmed);
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [value, isSubmitting, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
} else if (e.key === "Escape") {
e.preventDefault();
if (value.trim()) {
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
} else {
onClose?.();
}
}
},
[handleSend, value, onClose],
);
const isEmpty = value.trim().length === 0;
return (
<div className={cn("flex items-end gap-2 border-t border-border p-3", className)}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
disabled={isSubmitting}
aria-label="Message input"
className={cn(
"flex-1 resize-none bg-muted border border-border px-3 py-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-1 focus:ring-ring",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
style={{ minHeight: 40, maxHeight: 160, fieldSizing: "content" } as any}
rows={1}
/>
<Button
variant="default"
size="icon"
onClick={handleSend}
disabled={isEmpty || isSubmitting}
aria-label="Send message"
aria-disabled={isEmpty || isSubmitting}
className="h-10 w-10 shrink-0"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
);
}
```
Create `ui/src/components/ChatInput.test.tsx`:
- Import `render, screen, fireEvent` from `@testing-library/react`
- Test Enter sends: render with `onSend` spy, type "hello", fire Enter keydown (without shiftKey), assert `onSend` called with "hello"
- Test Shift+Enter does not send: fire keydown with `key: "Enter", shiftKey: true`, assert `onSend` NOT called
- Test Escape clears: type "hello", fire Escape, assert textarea value is empty
- Test Escape calls onClose when empty: render with `onClose` spy, fire Escape on empty textarea, assert `onClose` called
- Test disabled send button: render without typing, assert send button has `disabled` attribute
- Test isSubmitting: render with `isSubmitting={true}`, assert textarea is disabled
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx</automated>
</verify>
<acceptance_criteria>
- ui/src/components/ChatInput.tsx contains `aria-label="Message input"`
- ui/src/components/ChatInput.tsx contains `aria-label="Send message"`
- ui/src/components/ChatInput.tsx contains `e.key === "Enter" && !e.shiftKey`
- ui/src/components/ChatInput.tsx contains `e.key === "Escape"`
- ui/src/components/ChatInput.tsx contains `onClose?.()`
- ui/src/components/ChatInput.tsx contains `style={{ minHeight: 40, maxHeight: 160`
- ui/src/components/ChatInput.tsx exports `ChatInput`
- ui/src/components/ChatInput.test.tsx exits 0
</acceptance_criteria>
<done>ChatInput component handles auto-resize, Enter/Shift+Enter/Escape shortcuts, and submit/loading states. Test suite green.</done>
</task>
</tasks>
<verification>
- `pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx` passes
- `pnpm vitest run ui/src/components/ChatInput.test.tsx` passes
- `grep "rehype-highlight" ui/package.json` returns a match
- `grep ".hljs" ui/src/index.css` shows theme-aware overrides
</verification>
<success_criteria>
- ChatMarkdownMessage renders markdown with GFM, syntax-highlighted code blocks, language labels, and copy buttons
- ChatInput auto-resizes from 1 to 6 lines, sends on Enter, newline on Shift+Enter, clears/closes on Escape
- All three themes have matching highlight.js CSS applied via class overrides
- All component tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,550 @@
---
phase: 21-chat-foundation
plan: 03
type: execute
wave: 2
depends_on: ["21-01", "21-02"]
files_modified:
- ui/src/api/chat.ts
- ui/src/context/ChatPanelContext.tsx
- ui/src/hooks/useChatConversations.ts
- ui/src/hooks/useChatMessages.ts
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatConversationList.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/components/Layout.tsx
- ui/src/main.tsx
autonomous: true
requirements:
- HIST-02
- HIST-03
must_haves:
truths:
- "User can see a chat icon in the layout that toggles a right-side panel"
- "User can create a new conversation and see it in the sidebar list"
- "User can type a message, send it, and see it appear in the message list"
- "Conversation list is sorted by most recent, loads more via infinite scroll"
- "Opening chat panel closes the PropertiesPanel"
- "Chat panel open state persists in localStorage across page loads"
artifacts:
- path: "ui/src/api/chat.ts"
provides: "chatApi fetch wrappers for all endpoints"
exports: ["chatApi"]
- path: "ui/src/context/ChatPanelContext.tsx"
provides: "ChatPanelProvider with open/close state and active conversation"
exports: ["ChatPanelProvider", "useChatPanel"]
- path: "ui/src/hooks/useChatConversations.ts"
provides: "TanStack Query useInfiniteQuery wrapper for conversations"
exports: ["useChatConversations"]
- path: "ui/src/hooks/useChatMessages.ts"
provides: "TanStack Query wrapper for messages"
exports: ["useChatMessages"]
- path: "ui/src/components/ChatPanel.tsx"
provides: "Right-side drawer shell with conversation list and message area"
contains: "role=\"complementary\""
- path: "ui/src/components/ChatConversationList.tsx"
provides: "Sidebar conversation list with infinite scroll, pin/archive/delete actions"
contains: "IntersectionObserver"
- path: "ui/src/components/ChatMessageList.tsx"
provides: "Message thread rendering user and assistant messages"
contains: "role=\"log\""
key_links:
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/api/chat.ts"
via: "useChatConversations and useChatMessages hooks"
pattern: "useChatConversations|useChatMessages"
- from: "ui/src/components/Layout.tsx"
to: "ui/src/components/ChatPanel.tsx"
via: "ChatPanel rendered in flex row, toggle via ChatPanelContext"
pattern: "<ChatPanel"
- from: "ui/src/components/Layout.tsx"
to: "ui/src/context/ChatPanelContext.tsx"
via: "useChatPanel to close PropertiesPanel when chat opens"
pattern: "useChatPanel"
- from: "ui/src/components/ChatConversationList.tsx"
to: "ui/src/hooks/useChatConversations.ts"
via: "useInfiniteQuery for paginated conversation list"
pattern: "useChatConversations"
---
<objective>
Wire the chat UI together: API client, panel context, TanStack Query hooks, conversation list with infinite scroll, message list, and the chat panel drawer integrated into the Layout.
Purpose: This plan connects the backend (Plan 01) and presentational components (Plan 02) into a working end-to-end chat experience where users can create conversations, send messages, and browse history.
Output: A fully functional chat panel accessible from the Layout, with conversation CRUD and message display.
</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/phases/21-chat-foundation/21-RESEARCH.md
@.planning/phases/21-chat-foundation/21-UI-SPEC.md
@.planning/phases/21-chat-foundation/21-01-SUMMARY.md
@.planning/phases/21-chat-foundation/21-02-SUMMARY.md
<interfaces>
<!-- From Plan 01 outputs -->
From packages/shared/src/types/chat.ts:
```typescript
export interface ChatConversation {
id: string;
companyId: string;
title: string | null;
agentId: string | null;
pinnedAt: string | null;
archivedAt: string | null;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
role: "user" | "assistant" | "system";
content: string;
agentId: string | null;
createdAt: string;
}
export interface ChatConversationListResponse {
items: ChatConversation[];
hasMore: boolean;
}
```
API endpoints (from Plan 01):
- GET /api/companies/:companyId/conversations?cursor=&limit=
- POST /api/companies/:companyId/conversations
- GET /api/conversations/:id
- PATCH /api/conversations/:id
- DELETE /api/conversations/:id
- POST /api/conversations/:id/archive
- POST /api/conversations/:id/unarchive
- POST /api/conversations/:id/pin
- POST /api/conversations/:id/unpin
- GET /api/conversations/:id/messages?cursor=&limit=
- POST /api/conversations/:id/messages
<!-- From Plan 02 outputs -->
From ui/src/components/ChatMarkdownMessage.tsx:
```typescript
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string })
```
From ui/src/components/ChatInput.tsx:
```typescript
export function ChatInput({ onSend, onClose, isSubmitting, className }: {
onSend: (content: string) => void;
onClose?: () => void;
isSubmitting?: boolean;
className?: string;
})
```
<!-- Existing codebase interfaces -->
From ui/src/api/client.ts:
```typescript
// api is an axios-like client or fetch wrapper — used as api.get("/path"), api.post("/path", body)
```
From ui/src/context/PanelContext.tsx:
```typescript
export function usePanel(): {
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
togglePanelVisible: () => void;
// ...
}
```
From ui/src/context/CompanyContext.tsx:
```typescript
export function useCompany(): {
selectedCompanyId: string | null;
// ...
}
```
From ui/src/components/Layout.tsx (line 416):
```tsx
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main id="main-content" ...>
<Outlet />
</main>
<PropertiesPanel />
</div>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Chat API client, context provider, and TanStack Query hooks</name>
<files>
ui/src/api/chat.ts,
ui/src/context/ChatPanelContext.tsx,
ui/src/hooks/useChatConversations.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/main.tsx
</files>
<read_first>
ui/src/api/activity.ts,
ui/src/api/client.ts,
ui/src/context/PanelContext.tsx,
ui/src/context/CompanyContext.tsx,
ui/src/hooks/useKeyboardShortcuts.ts,
ui/src/main.tsx
</read_first>
<action>
1. Create `ui/src/api/chat.ts` following the pattern from `ui/src/api/activity.ts`:
```typescript
import type { ChatConversation, ChatConversationListResponse, ChatMessage } from "@paperclipai/shared";
import { api } from "./client";
export const chatApi = {
listConversations: (companyId: string, opts?: { cursor?: string; limit?: number }) => {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return api.get<ChatConversationListResponse>(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`);
},
createConversation: (companyId: string, data?: { title?: string }) =>
api.post<ChatConversation>(`/api/companies/${companyId}/conversations`, data ?? {}),
getConversation: (id: string) =>
api.get<ChatConversation>(`/api/conversations/${id}`),
updateConversation: (id: string, data: { title?: string }) =>
api.patch<ChatConversation>(`/api/conversations/${id}`, data),
deleteConversation: (id: string) =>
api.delete(`/api/conversations/${id}`),
archiveConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/archive`),
unarchiveConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/unarchive`),
pinConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/pin`),
unpinConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/unpin`),
listMessages: (conversationId: string, opts?: { cursor?: string; limit?: number }) => {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return api.get<{ items: ChatMessage[]; hasMore: boolean }>(`/api/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`);
},
sendMessage: (conversationId: string, data: { role: string; content: string; agentId?: string | null }) =>
api.post<ChatMessage>(`/api/conversations/${conversationId}/messages`, data),
};
```
2. Create `ui/src/context/ChatPanelContext.tsx` following the `PanelContext.tsx` localStorage pattern:
```typescript
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
const STORAGE_KEY = "nexus:chat-panel-open";
interface ChatPanelContextValue {
chatOpen: boolean;
setChatOpen: (open: boolean) => void;
toggleChat: () => void;
activeConversationId: string | null;
setActiveConversationId: (id: string | null) => void;
}
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
function readPreference(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === "true";
} catch {
return false;
}
}
function writePreference(open: boolean) {
try {
localStorage.setItem(STORAGE_KEY, String(open));
} catch { /* ignore */ }
}
export function ChatPanelProvider({ children }: { children: ReactNode }) {
const [chatOpen, setChatOpenState] = useState(readPreference);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const setChatOpen = useCallback((open: boolean) => {
setChatOpenState(open);
writePreference(open);
}, []);
const toggleChat = useCallback(() => {
setChatOpenState((prev) => {
const next = !prev;
writePreference(next);
return next;
});
}, []);
return (
<ChatPanelContext.Provider
value={{ chatOpen, setChatOpen, toggleChat, activeConversationId, setActiveConversationId }}
>
{children}
</ChatPanelContext.Provider>
);
}
export function useChatPanel() {
const ctx = useContext(ChatPanelContext);
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
return ctx;
}
```
3. Create `ui/src/hooks/useChatConversations.ts`:
```typescript
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
export function useChatConversations(companyId: string | null) {
return useInfiniteQuery({
queryKey: ["chat", "conversations", companyId],
queryFn: ({ pageParam }) =>
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
enabled: !!companyId,
});
}
export function useCreateConversation(companyId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data?: { title?: string }) =>
chatApi.createConversation(companyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
},
});
}
export function useConversationActions() {
const queryClient = useQueryClient();
return {
pin: useMutation({
mutationFn: chatApi.pinConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
unpin: useMutation({
mutationFn: chatApi.unpinConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
archive: useMutation({
mutationFn: chatApi.archiveConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
unarchive: useMutation({
mutationFn: chatApi.unarchiveConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
remove: useMutation({
mutationFn: chatApi.deleteConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
rename: useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
chatApi.updateConversation(id, { title }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
};
}
```
4. Create `ui/src/hooks/useChatMessages.ts`:
```typescript
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
export function useChatMessages(conversationId: string | null) {
return useInfiniteQuery({
queryKey: ["chat", "messages", conversationId],
queryFn: ({ pageParam }) =>
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
enabled: !!conversationId,
});
}
export function useSendMessage(conversationId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (content: string) =>
chatApi.sendMessage(conversationId!, { role: "user", content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
}
```
5. Add `ChatPanelProvider` to `ui/src/main.tsx`: wrap the app tree with `<ChatPanelProvider>` as a sibling of the existing providers. Import from `"./context/ChatPanelContext"`. Place it INSIDE the existing `<QueryClientProvider>` but outside `<RouterProvider>` (or at the same level as other context providers).
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "chatApi" ui/src/api/chat.ts && grep -c "useChatPanel" ui/src/context/ChatPanelContext.tsx && grep -c "ChatPanelProvider" ui/src/main.tsx</automated>
</verify>
<acceptance_criteria>
- ui/src/api/chat.ts exports `chatApi` with methods: listConversations, createConversation, getConversation, updateConversation, deleteConversation, archiveConversation, unarchiveConversation, pinConversation, unpinConversation, listMessages, sendMessage
- ui/src/context/ChatPanelContext.tsx contains `localStorage.getItem(STORAGE_KEY)` with `STORAGE_KEY = "nexus:chat-panel-open"`
- ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel`
- ui/src/hooks/useChatConversations.ts contains `useInfiniteQuery` and `getNextPageParam`
- ui/src/hooks/useChatMessages.ts contains `useInfiniteQuery`
- ui/src/main.tsx contains `ChatPanelProvider`
</acceptance_criteria>
<done>Chat API client, context, and hooks are wired. ChatPanelProvider is in the app tree.</done>
</task>
<task type="auto">
<name>Task 2: ChatPanel, ChatConversationList, ChatMessageList, and Layout integration</name>
<files>
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatConversationList.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/components/Layout.tsx
</files>
<read_first>
ui/src/components/Layout.tsx,
ui/src/components/PropertiesPanel.tsx,
ui/src/context/PanelContext.tsx,
ui/src/context/ChatPanelContext.tsx,
ui/src/context/CompanyContext.tsx,
ui/src/hooks/useChatConversations.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/components/ChatMarkdownMessage.tsx,
ui/src/components/ChatInput.tsx,
ui/src/api/chat.ts,
ui/src/components/ui/skeleton.tsx,
ui/src/components/ui/dropdown-menu.tsx,
ui/src/components/ui/scroll-area.tsx,
ui/src/components/ui/tooltip.tsx
</read_first>
<action>
1. Create `ui/src/components/ChatConversationList.tsx`:
A sidebar list of conversations with infinite scroll, inline actions, and inline rename.
Props: `{ companyId: string; activeId: string | null; onSelect: (id: string) => void; onNew: () => void }`
Implementation details:
- Uses `useChatConversations(companyId)` for paginated data
- Uses `useConversationActions()` for pin/archive/delete/rename mutations
- Renders `<nav aria-label="Conversations">` containing a scrollable `<ScrollArea>` with list items
- Each item: 48px height, `py-3 px-3` padding, `text-[13px]` title (truncated), `text-xs text-muted-foreground` timestamp right-aligned
- Active item: `border-l-2 border-primary bg-sidebar-accent`
- Hover: `bg-sidebar-accent/50` with a `<DropdownMenu>` trigger (MoreHorizontal icon) appearing on hover
- DropdownMenu items: "Rename conversation", "Pin/Unpin conversation", "Archive/Unarchive conversation", "Delete conversation" (text-destructive)
- Delete uses inline confirmation: when delete is clicked, replace the dropdown with a small popover showing "Delete this conversation?" with "Delete conversation" (variant="destructive") and "Keep conversation" (variant="ghost") buttons
- Inline rename: double-click title or "Rename" from dropdown swaps title text with an `<input>` at 13px font, Enter/blur confirms, Escape cancels
- Pinned conversations show filled Pin icon (14px, text-primary)
- Infinite scroll: sentinel `<div ref={sentinelRef}>` at bottom, `IntersectionObserver` triggers `fetchNextPage()` when visible. While loading next page, show 2 `<Skeleton className="h-12 mx-3 my-1">` items
- Loading state (initial): show 3 `<Skeleton>` items with `aria-busy="true"` on list container
- Empty state: centered text "No conversations yet" (text-sm text-muted-foreground), "Start a conversation to get help with your work." body, "New conversation" button
- Header: "Chat" title (text-base font-semibold), Plus icon button (tooltip "New conversation"), X icon button (close)
2. Create `ui/src/components/ChatMessageList.tsx`:
Message thread for a single conversation.
Props: `{ conversationId: string }`
Implementation details:
- Uses `useChatMessages(conversationId)` for data
- Container: `<div role="log" aria-live="polite">` with `p-4 gap-4 flex flex-col`
- User messages: right-aligned (`ml-auto`), `bg-secondary text-secondary-foreground`, `max-w-[75%]`, `px-4 py-2`, plain text (no markdown)
- Assistant messages: left-aligned, no background, `max-w-[85%]`, rendered via `<ChatMarkdownMessage content={msg.content} />`
- Timestamps: `text-xs text-muted-foreground`, visible on hover (`opacity-0 group-hover:opacity-100 transition-opacity`)
- Auto-scroll to bottom when new messages arrive: `useEffect` with `scrollIntoView({ behavior: "smooth" })` on a bottom sentinel ref
- Loading: single `<Skeleton>` block
- Empty (no messages yet): light prompt text "Send a message to start the conversation."
3. Create `ui/src/components/ChatPanel.tsx`:
The main right-side drawer shell that composes ChatConversationList, ChatMessageList, and ChatInput.
Implementation details:
- Uses `useChatPanel()` for open/close state and activeConversationId
- Uses `useCompany()` for selectedCompanyId
- Uses `useCreateConversation(companyId)` for creating new conversations
- Uses `useSendMessage(activeConversationId)` for sending messages
- Outer div: `role="complementary" aria-label="Chat"`, width transition `transition-[width] duration-100 ease-out`, width: `chatOpen ? 380 : 0`, `overflow-hidden`, `border-l border-border`, `flex-shrink-0`
- Internal layout when open: flex column, full height
- Top: header bar (48px, `border-b border-border`, "Chat" heading, plus button, close button)
- Middle: split horizontally — left side is `ChatConversationList` (240px wide, `bg-sidebar`, `border-r border-border`), right side is `ChatMessageList` (flex-1)
- When no activeConversationId: show conversation list full-width and empty state
- When activeConversationId set: show conversation list (240px) + message area
- Bottom: `ChatInput` with `onSend` that calls `sendMessage.mutateAsync(content)`, `onClose` that calls `setChatOpen(false)`, `isSubmitting` bound to `sendMessage.isPending`
- On "New conversation": call `createConversation.mutateAsync()`, set activeConversationId to the returned id, focus the ChatInput textarea
- Focus management: when panel opens, focus ChatInput. When new conversation created, focus ChatInput.
4. Modify `ui/src/components/Layout.tsx`:
- Add import: `import { ChatPanel } from "./ChatPanel";`
- Add import: `import { useChatPanel } from "../context/ChatPanelContext";`
- Add import: `import { MessageSquare } from "lucide-react";`
- In the `Layout()` function body, add: `const { chatOpen, toggleChat, setChatOpen } = useChatPanel();`
- Add effect: when `chatOpen` becomes true, call `setPanelVisible(false)` to close PropertiesPanel. This prevents both panels from competing for space.
- In the flex row at line 416 (`<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>`), AFTER `<PropertiesPanel />` (line 434), add `<ChatPanel />`.
- Add a chat toggle button in the top-right area of the layout (near the theme toggle button, around line 290-310). Use: `<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={toggleChat} aria-label={chatOpen ? "Close chat" : "Open chat"}><MessageSquare className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent></Tooltip>`
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "ChatPanel" ui/src/components/Layout.tsx && grep -c "role=\"complementary\"" ui/src/components/ChatPanel.tsx && grep -c "IntersectionObserver" ui/src/components/ChatConversationList.tsx && grep -c "role=\"log\"" ui/src/components/ChatMessageList.tsx</automated>
</verify>
<acceptance_criteria>
- ui/src/components/ChatPanel.tsx contains `role="complementary"` and `aria-label="Chat"`
- ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out`
- ui/src/components/ChatPanel.tsx contains `chatOpen ? 380 : 0`
- ui/src/components/ChatConversationList.tsx contains `<nav aria-label="Conversations"`
- ui/src/components/ChatConversationList.tsx contains `IntersectionObserver`
- ui/src/components/ChatConversationList.tsx contains `border-l-2 border-primary` for active state
- ui/src/components/ChatConversationList.tsx contains `"Delete this conversation?"` confirmation text
- ui/src/components/ChatConversationList.tsx contains `"No conversations yet"` empty state
- ui/src/components/ChatMessageList.tsx contains `role="log"` and `aria-live="polite"`
- ui/src/components/ChatMessageList.tsx contains `ChatMarkdownMessage`
- ui/src/components/Layout.tsx contains `import { ChatPanel }` and `<ChatPanel />`
- ui/src/components/Layout.tsx contains `useChatPanel`
- ui/src/components/Layout.tsx contains `MessageSquare`
- ui/src/components/Layout.tsx contains `setPanelVisible(false)` when chat opens
</acceptance_criteria>
<done>Chat panel is visible in Layout, conversation list shows with infinite scroll, messages render with markdown, input sends messages. Opening chat closes PropertiesPanel.</done>
</task>
</tasks>
<verification>
- App compiles without errors: `cd /Volumes/UsbNvme/repos/nexus && pnpm --filter @paperclipai/ui build` succeeds
- ChatPanel renders in Layout with width transition
- ChatConversationList uses IntersectionObserver for infinite scroll
- ChatMessageList renders messages with ChatMarkdownMessage
- Full test suite still passes: `pnpm test:run`
</verification>
<success_criteria>
- User can open/close chat panel via MessageSquare button in Layout
- User can create a new conversation via the plus button
- User can send a message and see it in the message list
- Conversation list shows sorted by most recent with infinite scroll
- Pin/archive/delete/rename work from dropdown menu
- Opening chat closes PropertiesPanel
- Panel state persists in localStorage
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`
</output>

View file

@ -0,0 +1,132 @@
---
phase: 21-chat-foundation
plan: 04
type: execute
wave: 3
depends_on: ["21-03"]
files_modified: []
autonomous: false
requirements:
- CHAT-02
- CHAT-03
- CHAT-04
- CHAT-05
- CHAT-06
- INPUT-01
- INPUT-07
- HIST-01
- HIST-02
- HIST-03
- HIST-05
- HIST-06
- THEME-01
- THEME-02
must_haves:
truths:
- "All phase 21 success criteria verified visually by user"
artifacts: []
key_links: []
---
<objective>
Visual and functional verification checkpoint for the complete Phase 21 chat foundation.
Purpose: Confirm the chat interface works end-to-end with correct theme integration, markdown rendering, and persistence before marking the phase complete.
Output: User approval or list of issues to fix.
</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/phases/21-chat-foundation/21-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Run full test suite and generate migration</name>
<files></files>
<read_first>
server/src/__tests__/chat-service.test.ts,
server/src/__tests__/chat-routes.test.ts
</read_first>
<action>
1. Run `pnpm db:generate` to generate the migration SQL if not already done. Verify generated SQL file exists under `packages/db/src/migrations/` and contains `CREATE TABLE "chat_conversations"` and `CREATE TABLE "chat_messages"` with `ON DELETE CASCADE`.
2. Run the full test suite: `pnpm test:run`
All tests must pass, including the new chat-service and chat-routes tests.
3. Run `pnpm --filter @paperclipai/ui build` to verify the UI compiles cleanly with all new components.
4. If the dev server is not running, start it: `pnpm dev`
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm test:run && pnpm --filter @paperclipai/ui build</automated>
</verify>
<acceptance_criteria>
- `pnpm test:run` exits 0
- `pnpm --filter @paperclipai/ui build` exits 0
- Migration SQL file exists in packages/db/src/migrations/ containing "chat_conversations" and "chat_messages"
</acceptance_criteria>
<done>Full test suite green, UI builds clean, migration SQL generated.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual and functional verification of chat foundation</name>
<files></files>
<action>
Present the running application to the user for visual verification of all Phase 21 requirements. Ensure the dev server is running at http://localhost:5173 and the database migration has been applied.
</action>
<verify>User provides "approved" signal</verify>
<done>User confirms chat foundation works correctly.</done>
<what-built>Complete chat foundation: right-side chat panel with conversation sidebar, markdown message rendering with syntax-highlighted code blocks and copy buttons, auto-resize input with keyboard shortcuts, theme-aware styling across all three themes, and persistent storage in PostgreSQL.</what-built>
<how-to-verify>
1. Open the app at http://localhost:5173
2. Click the chat icon (MessageSquare) in the top-right area of the layout — the chat panel should slide open from the right (380px wide)
3. Verify the PropertiesPanel (if visible) closes when chat opens
4. Click "New conversation" (Plus icon) — a conversation should appear in the sidebar list
5. Type "Hello, this is a test message" and press Enter — the message should appear right-aligned in the message area
6. Type a message with markdown: "Here is some **bold** and a code block:\n```typescript\nconst x: number = 42;\nconsole.log(x);\n```" and press Enter
7. Verify the code block has:
- Syntax highlighting (colored keywords)
- A "typescript" language label in the top-left
- A copy button in the top-right (click it — should show checkmark for 2 seconds)
8. Switch themes: click the theme toggle button. For each theme (Catppuccin Mocha, Tokyo Night, Catppuccin Latte):
- Verify the chat panel background matches the theme
- Verify code block highlighting colors change with the theme
- Verify text is readable and contrast is good
9. Test keyboard shortcuts:
- Shift+Enter in the input should create a newline (not send)
- Enter should send the message
- Escape with text in input should clear the input
- Escape with empty input should close the chat panel
10. Test conversation management: hover over a conversation in the sidebar, click the "..." menu:
- Pin the conversation (should show pin icon)
- Rename the conversation (should allow inline editing)
- Archive the conversation (should disappear from list)
- Create another conversation and delete it (should show "Delete this conversation?" confirmation)
11. Reload the page — conversations and messages should persist
12. Verify auto-resize: type multiple lines in the input — it should grow up to about 6 lines then scroll internally
</how-to-verify>
<resume-signal>Type "approved" or describe any issues found</resume-signal>
</task>
</tasks>
<verification>
Full visual and functional verification by user covering all 14 phase requirements.
</verification>
<success_criteria>
User approves the chat foundation as working correctly across all themes with proper markdown rendering, keyboard shortcuts, conversation CRUD, and persistence.
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-04-SUMMARY.md`
</output>