diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ab0a6192..d16e429c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/21-chat-foundation/21-01-PLAN.md b/.planning/phases/21-chat-foundation/21-01-PLAN.md new file mode 100644 index 00000000..fb1123e8 --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/21-chat-foundation/21-RESEARCH.md + + + + +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)); +``` + + + + + + + Task 1: DB schema, shared types, validators, and service with tests + + 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 + + + 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 + + + - 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 + + + 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 `` block above. + + + cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-service.test.ts + + + - 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 + + 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. + + + + Task 2: REST API routes and route tests + + server/src/routes/chat.ts, + server/src/routes/index.ts, + server/src/app.ts, + server/src/__tests__/chat-routes.test.ts + + + 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 + + + - 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 + + + 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 `` block + - Also mock `@paperclipai/shared` validators if needed, or let them pass through (they are pure Zod schemas that work without mocking) + + + cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-routes.test.ts + + + - 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 + + 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. + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/21-chat-foundation/21-01-SUMMARY.md` + diff --git a/.planning/phases/21-chat-foundation/21-02-PLAN.md b/.planning/phases/21-chat-foundation/21-02-PLAN.md new file mode 100644 index 00000000..bb458419 --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/21-chat-foundation/21-RESEARCH.md +@.planning/phases/21-chat-foundation/21-UI-SPEC.md + + + + +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: {content} +``` + +From ui/src/context/ThemeContext.tsx: +```typescript +export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte"; +export const THEME_META: Record; +// Theme classes on : .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