diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ab0a6192..bf522ee2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -2,7 +2,7 @@ **Milestone:** v1.3 **Status:** Queued (not yet active) -**Phases:** 21–26 (6 phases) +**Phases:** 21-26 (6 phases) **Granularity:** Standard **Coverage:** 65/65 requirements mapped @@ -31,7 +31,15 @@ 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:** 5 plans + +Plans: +- [ ] 21-01-PLAN.md — DB schema (chat_conversations + chat_messages) and shared types/validators +- [ ] 21-02-PLAN.md — Markdown renderer with rehype-highlight, code block copy button, theme CSS +- [ ] 21-03-PLAN.md — Server chat service and REST API routes (CRUD + pagination) +- [ ] 21-04-PLAN.md — ChatPanel shell, ChatPanelContext, ChatInput, Layout integration +- [ ] 21-05-PLAN.md — Full UI wiring: API client, conversation list, message thread, infinite scroll + **UI hint**: yes ### Phase 22: Agent Streaming @@ -180,7 +188,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/5 | Planned | - | | 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..7359f116 --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-01-PLAN.md @@ -0,0 +1,303 @@ +--- +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 + - packages/shared/src/types/index.ts + - packages/shared/src/validators/index.ts +autonomous: true +requirements: [HIST-01, HIST-05, HIST-06] + +must_haves: + truths: + - "chat_conversations and chat_messages tables exist in PostgreSQL after migration" + - "Conversations persist across server restarts (DB-backed, not in-memory)" + - "Shared types and Zod validators are importable from @paperclipai/shared" + artifacts: + - path: "packages/db/src/schema/chat_conversations.ts" + provides: "chatConversations Drizzle table" + exports: ["chatConversations"] + - path: "packages/db/src/schema/chat_messages.ts" + provides: "chatMessages Drizzle table" + exports: ["chatMessages"] + - path: "packages/shared/src/types/chat.ts" + provides: "ChatConversation and ChatMessage TypeScript types" + exports: ["ChatConversation", "ChatMessage", "ChatConversationListItem"] + - path: "packages/shared/src/validators/chat.ts" + provides: "Zod schemas for create/update operations" + exports: ["createConversationSchema", "updateConversationSchema", "createMessageSchema"] + key_links: + - from: "packages/db/src/schema/chat_conversations.ts" + to: "packages/db/src/schema/companies.ts" + via: "FK companyId references companies.id" + pattern: "references.*companies\\.id" + - from: "packages/db/src/schema/chat_messages.ts" + to: "packages/db/src/schema/chat_conversations.ts" + via: "FK conversationId references chatConversations.id with onDelete cascade" + pattern: "onDelete.*cascade" + - 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 and shared types for the chat system. + +Purpose: Establish the persistence layer that all subsequent plans depend on — two new Drizzle tables (chat_conversations, chat_messages), a migration, and shared TypeScript types + Zod validators. +Output: Migration SQL applied, tables created, types and validators importable from @paperclipai/shared. + + + +@$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: +```typescript +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +``` + +From packages/db/src/schema/companies.ts: +```typescript +export const companies = pgTable("companies", { id: uuid("id").primaryKey().defaultRandom(), ... }); +``` + +From packages/db/src/schema/agents.ts: +```typescript +export const agents = pgTable("agents", { id: uuid("id").primaryKey().defaultRandom(), ... }); +``` + +From packages/shared/src/types/index.ts — re-exports all type modules. +From packages/shared/src/validators/index.ts — re-exports all validator modules. + + + + + + + Task 1: Create Drizzle schema files and generate migration + packages/db/src/schema/chat_conversations.ts, packages/db/src/schema/chat_messages.ts, packages/db/src/schema/index.ts + + - packages/db/src/schema/documents.ts (reference pattern for pgTable, timestamps, indexes) + - packages/db/src/schema/companies.ts (FK target for companyId) + - packages/db/src/schema/agents.ts (FK target for agentId) + - packages/db/src/schema/index.ts (current re-exports to extend) + + +Create `packages/db/src/schema/chat_conversations.ts`: +```typescript +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; + +export const chatConversations = pgTable( + "chat_conversations", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + title: text("title"), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }), + pinnedAt: timestamp("pinned_at", { withTimezone: true }), + archivedAt: timestamp("archived_at", { withTimezone: true }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("chat_conversations_company_updated_idx").on(table.companyId, table.updatedAt), + index("chat_conversations_company_deleted_idx").on(table.companyId, table.deletedAt), + ], +); +``` + +Create `packages/db/src/schema/chat_messages.ts`: +```typescript +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +import { chatConversations } from "./chat_conversations.js"; + +export const chatMessages = pgTable( + "chat_messages", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id").notNull() + .references(() => chatConversations.id, { onDelete: "cascade" }), + role: text("role").notNull(), + content: text("content").notNull(), + agentId: uuid("agent_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt), + ], +); +``` + +Add to `packages/db/src/schema/index.ts` at the end: +```typescript +export { chatConversations } from "./chat_conversations.js"; +export { chatMessages } from "./chat_messages.js"; +``` + +IMPORTANT: Check the index helper pattern in the existing schema files — Drizzle v0.38 may use the array syntax `(table) => [index(...)]` instead of the object syntax `(table) => ({...})`. Match whichever pattern the existing `documents.ts` or `issues.ts` uses. + +Then run: +```bash +cd /opt/nexus && pnpm db:generate +``` +This generates the migration SQL file. Inspect it to confirm it contains `CREATE TABLE chat_conversations`, `CREATE TABLE chat_messages`, `ON DELETE CASCADE`, and the two indexes. Do NOT run `pnpm db:migrate` — that happens at server start. + + + cd /opt/nexus && grep -r "chat_conversations" packages/db/src/schema/index.ts && grep -r "chat_messages" packages/db/src/schema/index.ts && ls packages/db/src/migrations/*.sql | tail -1 | xargs grep -l "chat_conversations" + + + - packages/db/src/schema/chat_conversations.ts contains `export const chatConversations = pgTable(` + - packages/db/src/schema/chat_messages.ts contains `export const chatMessages = pgTable(` + - packages/db/src/schema/chat_messages.ts contains `onDelete: "cascade"` + - 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"` + - A migration SQL file exists in packages/db/src/migrations/ containing `CREATE TABLE "chat_conversations"` + - The migration SQL contains `ON DELETE CASCADE` + - The migration SQL contains `chat_conversations_company_updated_idx` + + Both Drizzle schema files exist, are exported from index.ts, and a migration SQL has been generated containing the correct DDL with FK constraints and indexes. + + + + Task 2: Create shared types and Zod validators for chat + packages/shared/src/types/chat.ts, packages/shared/src/validators/chat.ts, packages/shared/src/types/index.ts, packages/shared/src/validators/index.ts + + - packages/shared/src/types/company.ts (reference pattern for type definitions) + - packages/shared/src/validators/company.ts (reference pattern for Zod schemas) + - packages/shared/src/types/index.ts (current re-exports) + - packages/shared/src/validators/index.ts (current re-exports) + + +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 ChatConversationListItem { + id: string; + companyId: string; + title: string | null; + agentId: string | null; + pinnedAt: string | null; + archivedAt: string | null; + updatedAt: string; + lastMessagePreview: string | null; +} + +export interface ChatMessage { + id: string; + conversationId: string; + role: "user" | "assistant" | "system"; + content: string; + agentId: string | null; + createdAt: string; +} + +export interface ChatConversationListResponse { + items: ChatConversationListItem[]; + hasMore: boolean; +} + +export interface ChatMessageListResponse { + items: ChatMessage[]; + hasMore: boolean; +} +``` + +Create `packages/shared/src/validators/chat.ts`: +```typescript +import { z } from "zod"; + +export const createConversationSchema = z.object({ + title: z.string().max(200).optional(), + agentId: z.string().uuid().optional(), +}); + +export const updateConversationSchema = z.object({ + title: z.string().max(200).optional(), + agentId: z.string().uuid().nullable().optional(), + pinnedAt: z.string().datetime().nullable().optional(), + archivedAt: z.string().datetime().nullable().optional(), +}); + +export const createMessageSchema = z.object({ + role: z.enum(["user", "assistant", "system"]), + content: z.string().min(1).max(100_000), + agentId: z.string().uuid().optional(), +}); +``` + +Add to `packages/shared/src/types/index.ts`: +```typescript +export * from "./chat.js"; +``` + +Add to `packages/shared/src/validators/index.ts`: +```typescript +export * from "./chat.js"; +``` + + + cd /opt/nexus && npx tsx -e "import { createConversationSchema, createMessageSchema } from '@paperclipai/shared'; console.log('validators OK');" 2>/dev/null || grep -q "createConversationSchema" packages/shared/src/validators/chat.ts && grep -q "ChatConversation" packages/shared/src/types/chat.ts && echo "files OK" + + + - packages/shared/src/types/chat.ts contains `export interface ChatConversation` + - packages/shared/src/types/chat.ts contains `export interface ChatMessage` + - packages/shared/src/types/chat.ts contains `export interface ChatConversationListItem` + - packages/shared/src/validators/chat.ts contains `export const createConversationSchema` + - packages/shared/src/validators/chat.ts contains `export const updateConversationSchema` + - packages/shared/src/validators/chat.ts contains `export const createMessageSchema` + - packages/shared/src/types/index.ts contains `export * from "./chat.js"` + - packages/shared/src/validators/index.ts contains `export * from "./chat.js"` + + Chat types (ChatConversation, ChatMessage, ChatConversationListItem) and Zod validators (createConversationSchema, updateConversationSchema, createMessageSchema) exist and are re-exported from the shared package barrel files. + + + + + +- Migration SQL file contains CREATE TABLE for both chat_conversations and chat_messages +- Schema files follow existing Drizzle pattern (pgTable, uuid PKs, timestamp with timezone) +- chat_messages FK has ON DELETE CASCADE +- Types and validators importable from @paperclipai/shared + + + +- Two new Drizzle schema files created and exported +- Migration SQL generated with correct DDL +- Shared types and Zod validators created and re-exported +- No modifications to existing tables + + + +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..62869208 --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-02-PLAN.md @@ -0,0 +1,272 @@ +--- +phase: 21-chat-foundation +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ui/src/components/ChatMarkdownMessage.tsx + - ui/src/components/ChatCodeBlock.tsx + - ui/src/index.css + - ui/package.json +autonomous: true +requirements: [CHAT-02, CHAT-03, THEME-02] + +must_haves: + truths: + - "Markdown messages render with syntax-highlighted code blocks" + - "Code blocks show a language label and a one-click copy button" + - "Code block highlighting changes correctly when switching between Catppuccin Mocha, Tokyo Night, and Catppuccin Latte" + artifacts: + - path: "ui/src/components/ChatMarkdownMessage.tsx" + provides: "Markdown renderer with rehype-highlight" + exports: ["ChatMarkdownMessage"] + - path: "ui/src/components/ChatCodeBlock.tsx" + provides: "Code block wrapper with copy button and language label" + exports: ["ChatCodeBlock"] + key_links: + - from: "ui/src/components/ChatMarkdownMessage.tsx" + to: "rehype-highlight" + via: "rehypePlugins prop on react-markdown" + pattern: "rehypePlugins.*rehypeHighlight" + - from: "ui/src/components/ChatCodeBlock.tsx" + to: "navigator.clipboard" + via: "writeText call on copy button click" + pattern: "navigator\\.clipboard\\.writeText" + - from: "ui/src/index.css" + to: "highlight.js themes" + via: "CSS overrides per theme class (.dark .hljs, .theme-tokyo-night .hljs)" + pattern: "\\.hljs" +--- + + +Install rehype-highlight and build the theme-aware markdown message renderer with code block copy functionality. + +Purpose: Satisfy CHAT-02 (markdown rendering with syntax highlighting), CHAT-03 (copy button + language label on code blocks), and THEME-02 (theme-appropriate highlighting colors). This is the rendering layer used by the message list in later plans. +Output: ChatMarkdownMessage and ChatCodeBlock components, ready for use. + + + +@$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 { cn } from "../lib/utils"; +import { useTheme, THEME_META } from "../context/ThemeContext"; +``` + +From ui/src/context/ThemeContext.tsx: +```typescript +export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte"; +export const THEME_META: Record; +export function useTheme(): { theme: Theme; toggleTheme: () => void }; +``` + + + + + + + Task 1: Install rehype-highlight and add hljs theme CSS overrides + ui/package.json, ui/src/index.css + + - ui/package.json (current dependencies) + - ui/src/index.css (existing theme CSS variables and .dark/.theme-tokyo-night selectors) + + +Install the dependency: +```bash +cd /opt/nexus && pnpm --filter @paperclipai/ui add rehype-highlight +``` + +Then add highlight.js theme CSS overrides to `ui/src/index.css`. Do NOT import highlight.js CSS files directly — instead, add CSS custom property overrides scoped to each theme class. Add the following AFTER the existing theme variable blocks (after the `.theme-tokyo-night.dark` block), but BEFORE any component-specific styles: + +```css +/* ── highlight.js syntax theme overrides (chat code blocks) ───────────── */ + +/* Base hljs reset — ensure code blocks use our themed variables */ +.hljs { + background: var(--code-block-bg, hsl(var(--card))) !important; + color: var(--code-block-fg, hsl(var(--card-foreground))) !important; +} + +/* Catppuccin Mocha (default dark) */ +.dark .hljs { --code-block-bg: #1e1e2e; --code-block-fg: #cdd6f4; } +.dark .hljs-keyword { color: #cba6f7; } +.dark .hljs-string { color: #a6e3a1; } +.dark .hljs-number { color: #fab387; } +.dark .hljs-comment { color: #6c7086; font-style: italic; } +.dark .hljs-function { color: #89b4fa; } +.dark .hljs-title { color: #89b4fa; } +.dark .hljs-built_in { color: #f38ba8; } +.dark .hljs-type { color: #f9e2af; } +.dark .hljs-attr { color: #89dceb; } +.dark .hljs-variable { color: #cdd6f4; } +.dark .hljs-literal { color: #fab387; } +.dark .hljs-meta { color: #f5e0dc; } +.dark .hljs-selector-class { color: #89dceb; } +.dark .hljs-selector-tag { color: #cba6f7; } + +/* Tokyo Night */ +.theme-tokyo-night .hljs { --code-block-bg: #1a1b26; --code-block-fg: #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; font-style: italic; } +.theme-tokyo-night .hljs-function { color: #7aa2f7; } +.theme-tokyo-night .hljs-title { color: #7aa2f7; } +.theme-tokyo-night .hljs-built_in { color: #f7768e; } +.theme-tokyo-night .hljs-type { color: #e0af68; } +.theme-tokyo-night .hljs-attr { color: #73daca; } +.theme-tokyo-night .hljs-variable { color: #a9b1d6; } +.theme-tokyo-night .hljs-literal { color: #ff9e64; } +.theme-tokyo-night .hljs-meta { color: #c0caf5; } +.theme-tokyo-night .hljs-selector-class { color: #73daca; } +.theme-tokyo-night .hljs-selector-tag { color: #bb9af7; } + +/* Catppuccin Latte (light) */ +:root .hljs { --code-block-bg: #eff1f5; --code-block-fg: #4c4f69; } +:root .hljs-keyword { color: #8839ef; } +:root .hljs-string { color: #40a02b; } +:root .hljs-number { color: #fe640b; } +:root .hljs-comment { color: #9ca0b0; font-style: italic; } +:root .hljs-function { color: #1e66f5; } +:root .hljs-title { color: #1e66f5; } +:root .hljs-built_in { color: #d20f39; } +:root .hljs-type { color: #df8e1d; } +:root .hljs-attr { color: #179299; } +:root .hljs-variable { color: #4c4f69; } +:root .hljs-literal { color: #fe640b; } +:root .hljs-meta { color: #dc8a78; } +:root .hljs-selector-class { color: #179299; } +:root .hljs-selector-tag { color: #8839ef; } +``` + +IMPORTANT: The `.dark` selector matches Catppuccin Mocha. The `.theme-tokyo-night` selector overrides for Tokyo Night. The `:root` selector (without `.dark`) matches Catppuccin Latte. This aligns with the existing theme CSS variable structure in index.css. + + + cd /opt/nexus && grep -q "rehype-highlight" ui/package.json && grep -q "\.hljs-keyword" ui/src/index.css && grep -q "theme-tokyo-night .hljs" ui/src/index.css && echo "OK" + + + - ui/package.json contains "rehype-highlight" in dependencies + - ui/src/index.css contains `.dark .hljs-keyword { color: #cba6f7; }` + - ui/src/index.css contains `.theme-tokyo-night .hljs-keyword { color: #bb9af7; }` + - ui/src/index.css contains `:root .hljs-keyword { color: #8839ef; }` + - The CSS block appears after existing theme variable blocks, not inside them + + rehype-highlight is installed, and highlight.js theme CSS overrides exist in index.css for all three Nexus themes (Catppuccin Mocha, Tokyo Night, Catppuccin Latte). + + + + Task 2: Create ChatCodeBlock and ChatMarkdownMessage components + ui/src/components/ChatCodeBlock.tsx, ui/src/components/ChatMarkdownMessage.tsx + + - ui/src/components/MarkdownBody.tsx (existing markdown component — understand the Components override pattern, mermaid handling, and remark-gfm usage) + - ui/src/context/ThemeContext.tsx (useTheme, THEME_META exports) + - ui/src/lib/utils.ts (cn utility) + + +Create `ui/src/components/ChatCodeBlock.tsx`: + +A `pre` component override for react-markdown that wraps code blocks with: +1. A toolbar bar at the top showing the language label (extracted from the `className` on the child `` element, e.g., `language-typescript` -> `typescript`) +2. A copy button in the toolbar that calls `navigator.clipboard.writeText(codeText)` where `codeText` is extracted by recursively flattening the children's text content +3. Copy button shows `Copy` icon (lucide-react) by default, switches to `Check` icon for 1500ms after a successful copy, then reverts +4. Toolbar background: `bg-card` — same as code block background, with `border-b border-border` +5. Language label: `text-xs text-muted-foreground font-mono` +6. Copy button: `Button variant="ghost" size="icon"` with `className="h-6 w-6"`, `aria-label="Copy code"` (changes to `"Copied!"` during success state) +7. For `pre` blocks without a code child (plain preformatted text), render a plain `
` without the toolbar
+
+Component signature:
+```typescript
+interface ChatCodeBlockProps {
+  children?: React.ReactNode;
+  className?: string;
+  [key: string]: unknown;
+}
+export function ChatCodeBlock({ children, className, ...props }: ChatCodeBlockProps): JSX.Element;
+```
+
+Create `ui/src/components/ChatMarkdownMessage.tsx`:
+
+Builds on the existing `MarkdownBody` pattern but uses `rehype-highlight` for syntax highlighting and the custom `ChatCodeBlock` for code block rendering.
+
+```typescript
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeHighlight from "rehype-highlight";
+import { ChatCodeBlock } from "./ChatCodeBlock";
+import { cn } from "../lib/utils";
+
+interface ChatMarkdownMessageProps {
+  content: string;
+  className?: string;
+}
+
+export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
+  return (
+    
+ + {content} + +
+ ); +} +``` + +The `paperclip-markdown` class ensures existing markdown prose styles from index.css apply (font-size 0.9375rem, line-height 1.6, heading styles, table styles, etc.). + +Do NOT duplicate mermaid handling from MarkdownBody — mermaid diagrams are not expected in chat responses for Phase 21. If needed later, it can be added. + + + cd /opt/nexus && grep -q "rehypeHighlight" ui/src/components/ChatMarkdownMessage.tsx && grep -q "ChatCodeBlock" ui/src/components/ChatMarkdownMessage.tsx && grep -q "navigator.clipboard.writeText" ui/src/components/ChatCodeBlock.tsx && echo "OK" + + + - 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 `pre: ChatCodeBlock` + - ui/src/components/ChatMarkdownMessage.tsx exports `ChatMarkdownMessage` + - ui/src/components/ChatCodeBlock.tsx contains `navigator.clipboard.writeText` + - ui/src/components/ChatCodeBlock.tsx contains `aria-label` with "Copy code" or "Copied!" + - ui/src/components/ChatCodeBlock.tsx extracts language from className pattern `language-` + - ui/src/components/ChatCodeBlock.tsx uses `Check` and `Copy` icons from lucide-react + + ChatMarkdownMessage renders markdown with syntax-highlighted code blocks via rehype-highlight. ChatCodeBlock shows a language label and copy button on every code block, with a 1500ms success state on copy. + + + + + +- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes (type checks) +- Code blocks in ChatMarkdownMessage render with .hljs classes +- Copy button wires to navigator.clipboard.writeText +- Theme CSS in index.css covers all three themes + + + +- rehype-highlight installed in ui package +- ChatMarkdownMessage renders markdown with syntax highlighting +- ChatCodeBlock provides language label + copy button +- highlight.js theme overrides in index.css for Mocha, Tokyo Night, and Latte + + + +After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md` + diff --git a/.planning/phases/21-chat-foundation/21-03-PLAN.md b/.planning/phases/21-chat-foundation/21-03-PLAN.md new file mode 100644 index 00000000..5ab3389b --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-03-PLAN.md @@ -0,0 +1,358 @@ +--- +phase: 21-chat-foundation +plan: 03 +type: execute +wave: 2 +depends_on: ["21-01"] +files_modified: + - server/src/services/chat.ts + - server/src/routes/chat.ts + - server/src/app.ts +autonomous: true +requirements: [CHAT-04, CHAT-05, CHAT-06] + +must_haves: + truths: + - "POST /api/companies/:companyId/conversations creates a conversation row in DB" + - "GET /api/companies/:companyId/conversations returns paginated list sorted by updatedAt DESC" + - "POST /api/conversations/:id/messages creates a message and bumps conversation updatedAt" + - "First message on a title-less conversation auto-sets the title to the first 60 chars" + - "PATCH /api/conversations/:id can set pinnedAt, archivedAt, and title" + - "DELETE /api/conversations/:id soft-deletes by setting deletedAt" + artifacts: + - path: "server/src/services/chat.ts" + provides: "chatService factory with all CRUD methods" + exports: ["chatService"] + - path: "server/src/routes/chat.ts" + provides: "chatRoutes factory returning Express Router" + exports: ["chatRoutes"] + key_links: + - from: "server/src/routes/chat.ts" + to: "server/src/services/chat.ts" + via: "chatService(db) instantiation" + pattern: "chatService\\(db\\)" + - from: "server/src/app.ts" + to: "server/src/routes/chat.ts" + via: "api.use(chatRoutes(db))" + pattern: "chatRoutes\\(db\\)" + - from: "server/src/services/chat.ts" + to: "packages/db/src/schema/chat_conversations.ts" + via: "Drizzle query on chatConversations table" + pattern: "chatConversations" +--- + + +Build the server-side chat service and REST API routes. + +Purpose: Provide the backend for conversation CRUD (create, list, update, pin, archive, soft-delete) and message CRUD (create, list) with cursor-based pagination. This is the data layer the UI consumes. +Output: Working API endpoints mounted on the Express app. + + + +@$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-01-SUMMARY.md + + +From packages/db/src/schema/chat_conversations.ts (created in Plan 01): +```typescript +export const chatConversations = pgTable("chat_conversations", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + title: text("title"), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }), + pinnedAt: timestamp("pinned_at", { withTimezone: true }), + archivedAt: timestamp("archived_at", { withTimezone: true }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +From packages/db/src/schema/chat_messages.ts (created in Plan 01): +```typescript +export const chatMessages = pgTable("chat_messages", { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id").notNull().references(() => chatConversations.id, { onDelete: "cascade" }), + role: text("role").notNull(), + content: text("content").notNull(), + agentId: uuid("agent_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +From packages/shared/src/validators/chat.ts (created in Plan 01): +```typescript +export const createConversationSchema = z.object({ title: z.string().max(200).optional(), agentId: z.string().uuid().optional() }); +export const updateConversationSchema = z.object({ title: z.string().max(200).optional(), agentId: z.string().uuid().nullable().optional(), pinnedAt: z.string().datetime().nullable().optional(), archivedAt: z.string().datetime().nullable().optional() }); +export const createMessageSchema = z.object({ role: z.enum(["user", "assistant", "system"]), content: z.string().min(1).max(100_000), agentId: z.string().uuid().optional() }); +``` + +From server/src/routes/authz.ts: +```typescript +export function assertBoard(req: Request): void; +export function assertCompanyAccess(req: Request, companyId: string): void; +``` + +From server/src/errors.ts: +```typescript +export function notFound(message?: string): HttpError; +export function unprocessable(message: string, issues?: unknown): HttpError; +``` + + + + + + + Task 1: Create chat service with CRUD operations + server/src/services/chat.ts + + - server/src/services/documents.ts (reference for service factory pattern, Drizzle query patterns) + - server/src/services/activity.ts (reference for simpler service pattern) + - packages/db/src/schema/chat_conversations.ts (table definition — will exist from Plan 01) + - packages/db/src/schema/chat_messages.ts (table definition — will exist from Plan 01) + + +Create `server/src/services/chat.ts` following the `function chatService(db: Db)` factory pattern: + +```typescript +import { and, asc, desc, eq, isNull, lt, sql, count } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { chatConversations, chatMessages } from "@paperclipai/db"; +import { notFound } from "../errors.js"; + +export function chatService(db: Db) { + return { + async listConversations(companyId: string, opts: { cursor?: string; limit?: number; includeArchived?: boolean }) { + // ... + }, + async createConversation(companyId: string, data: { title?: string; agentId?: string }) { + // ... + }, + async getConversation(id: string) { + // ... + }, + async updateConversation(id: string, data: { title?: string; agentId?: string | null; pinnedAt?: string | null; archivedAt?: string | null }) { + // ... + }, + async softDeleteConversation(id: string) { + // ... + }, + async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) { + // ... + }, + async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string }) { + // ... + }, + }; +} +``` + +Implementation details for each method: + +**listConversations:** +- `limit` defaults to 30, max 100: `const limit = Math.min(opts.limit ?? 30, 100);` +- Filter: `companyId` matches, `deletedAt IS NULL`. If `includeArchived` is false (default), also filter `archivedAt IS NULL`. +- Cursor: if `opts.cursor` provided, add `lt(chatConversations.updatedAt, new Date(opts.cursor))` +- Order: `desc(chatConversations.updatedAt)` +- Pagination: fetch `limit + 1`, if `rows.length > limit` then `hasMore = true`, return `rows.slice(0, limit)` +- Also do a lateral subquery or second query to get `lastMessagePreview`: for each conversation, get the most recent message content truncated to 100 chars. If that's too complex, return `lastMessagePreview: null` for now and add it in a follow-up. + +**createConversation:** +- Insert into `chatConversations` with `companyId`, optional `title`, optional `agentId` +- Return the inserted row + +**getConversation:** +- Select where `id` matches and `deletedAt IS NULL` +- Throw `notFound("Conversation not found")` if no row + +**updateConversation:** +- Build a partial update object from provided fields +- For `pinnedAt` and `archivedAt`: if value is a string, convert to `new Date(value)`. If value is `null`, set column to `null`. +- Also set `updatedAt: new Date()` on every update +- Use `RETURNING *` to get the updated row + +**softDeleteConversation:** +- `UPDATE chat_conversations SET deleted_at = now(), updated_at = now() WHERE id = $id AND deleted_at IS NULL` +- Return the updated row or throw notFound + +**listMessages:** +- `limit` defaults to 50, max 200 +- Filter: `conversationId` matches +- Cursor: if `opts.cursor` provided, add `lt(chatMessages.createdAt, new Date(opts.cursor))` +- Order: `desc(chatMessages.createdAt)` (most recent first) +- Same pagination pattern as conversations + +**addMessage:** +- Insert into `chatMessages` with `conversationId`, `role`, `content`, optional `agentId` +- CRITICAL (Pitfall 3 from RESEARCH.md): After inserting the message, also UPDATE the conversation's `updatedAt` to `now()`: + ```typescript + await db.update(chatConversations) + .set({ updatedAt: new Date() }) + .where(eq(chatConversations.id, conversationId)); + ``` +- CRITICAL (Pitfall 5 from RESEARCH.md): Auto-title generation — if this is the first message (role === "user") and the conversation has no title: + ```typescript + await db.update(chatConversations) + .set({ title: data.content.slice(0, 60), updatedAt: new Date() }) + .where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title))); + ``` + Use `WHERE title IS NULL` to make it idempotent. +- Return the inserted message row + + + cd /opt/nexus && grep -q "export function chatService" server/src/services/chat.ts && grep -q "listConversations" server/src/services/chat.ts && grep -q "addMessage" server/src/services/chat.ts && grep -q "isNull(chatConversations.title)" server/src/services/chat.ts && echo "OK" + + + - server/src/services/chat.ts contains `export function chatService(db: Db)` + - Contains methods: listConversations, createConversation, getConversation, updateConversation, softDeleteConversation, listMessages, addMessage + - addMessage updates `chatConversations.updatedAt` after inserting message (Pitfall 3) + - addMessage auto-sets title when `title IS NULL` using `data.content.slice(0, 60)` (Pitfall 5) + - listConversations filters `isNull(chatConversations.deletedAt)` + - listConversations uses `desc(chatConversations.updatedAt)` ordering + - Pagination uses `limit + 1` pattern with `hasMore` boolean + + Chat service exports a factory function with 7 CRUD methods covering conversation lifecycle (create, list, get, update, soft-delete) and message operations (list, add), including auto-title generation and updatedAt bumping. + + + + Task 2: Create chat routes and mount in app.ts + server/src/routes/chat.ts, server/src/app.ts + + - server/src/routes/activity.ts (reference route factory pattern: function activityRoutes(db: Db): Router) + - server/src/routes/authz.ts (assertBoard, assertCompanyAccess imports) + - server/src/app.ts (existing route mounting pattern: api.use(fooRoutes(db))) + - server/src/middleware/validate.ts (if exists — validation middleware pattern) + + +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 { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared"; + +export function chatRoutes(db: Db): Router { + const router = Router(); + const svc = chatService(db); + + // GET /api/companies/:companyId/conversations + router.get("/companies/:companyId/conversations", async (req, res) => { + assertBoard(req); + assertCompanyAccess(req, req.params.companyId!); + const { cursor, limit, includeArchived } = req.query; + const result = await svc.listConversations(req.params.companyId!, { + cursor: cursor as string | undefined, + limit: limit ? Number(limit) : undefined, + includeArchived: includeArchived === "true", + }); + res.json(result); + }); + + // POST /api/companies/:companyId/conversations + router.post("/companies/:companyId/conversations", async (req, res) => { + assertBoard(req); + assertCompanyAccess(req, req.params.companyId!); + const data = createConversationSchema.parse(req.body); + const conversation = await svc.createConversation(req.params.companyId!, data); + 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!); + res.json(conversation); + }); + + // PATCH /api/conversations/:id + router.patch("/conversations/:id", async (req, res) => { + assertBoard(req); + const data = updateConversationSchema.parse(req.body); + const conversation = await svc.updateConversation(req.params.id!, data); + res.json(conversation); + }); + + // DELETE /api/conversations/:id + router.delete("/conversations/:id", async (req, res) => { + assertBoard(req); + await svc.softDeleteConversation(req.params.id!); + res.status(204).end(); + }); + + // GET /api/conversations/:id/messages + router.get("/conversations/:id/messages", async (req, res) => { + assertBoard(req); + const { cursor, limit } = req.query; + const result = await svc.listMessages(req.params.id!, { + cursor: cursor as string | undefined, + limit: limit ? Number(limit) : undefined, + }); + res.json(result); + }); + + // POST /api/conversations/:id/messages + router.post("/conversations/:id/messages", async (req, res) => { + assertBoard(req); + const data = createMessageSchema.parse(req.body); + const message = await svc.addMessage(req.params.id!, data); + res.status(201).json(message); + }); + + return router; +} +``` + +NOTE: Check if existing routes wrap async handlers with a try/catch or rely on Express 5's built-in async error handling. Express 5.1.0 natively handles rejected promises in async route handlers, so no manual try/catch wrapper is needed unless the existing pattern uses one. + +Mount in `server/src/app.ts`: +1. Add import at the top with the other route imports: `import { chatRoutes } from "./routes/chat.js";` +2. Add `api.use(chatRoutes(db));` in the route mounting section, after the `activityRoutes` line (around line 158). + + + cd /opt/nexus && grep -q "chatRoutes" server/src/routes/chat.ts && grep -q "chatRoutes" server/src/app.ts && grep -q 'companies/:companyId/conversations' server/src/routes/chat.ts && grep -q 'conversations/:id/messages' server/src/routes/chat.ts && echo "OK" + + + - server/src/routes/chat.ts contains `export function chatRoutes(db: Db): Router` + - server/src/routes/chat.ts contains route `GET /companies/:companyId/conversations` + - server/src/routes/chat.ts contains route `POST /companies/:companyId/conversations` + - server/src/routes/chat.ts contains route `GET /conversations/:id` + - server/src/routes/chat.ts contains route `PATCH /conversations/:id` + - server/src/routes/chat.ts contains route `DELETE /conversations/:id` + - server/src/routes/chat.ts contains route `GET /conversations/:id/messages` + - server/src/routes/chat.ts contains route `POST /conversations/:id/messages` + - server/src/routes/chat.ts calls `assertBoard(req)` on every route + - server/src/routes/chat.ts calls `assertCompanyAccess(req, req.params.companyId!)` on company-scoped routes + - server/src/app.ts contains `import { chatRoutes } from "./routes/chat.js"` + - server/src/app.ts contains `chatRoutes(db)` + + Chat API routes are mounted on the Express app with 7 endpoints covering conversation CRUD and message CRUD, all gated by assertBoard auth. + + + + + +- `cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit` passes +- Routes follow the factory pattern used by all other route files +- Service correctly handles all pitfalls from RESEARCH.md (updatedAt bump, auto-title, soft delete) + + + +- Chat service has 7 methods covering full conversation + message CRUD +- Chat routes mounted in app.ts with proper auth +- Pagination uses cursor-based approach with hasMore +- Auto-title and updatedAt bump implemented per RESEARCH.md pitfalls + + + +After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md` + diff --git a/.planning/phases/21-chat-foundation/21-04-PLAN.md b/.planning/phases/21-chat-foundation/21-04-PLAN.md new file mode 100644 index 00000000..d4e7ca1a --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-04-PLAN.md @@ -0,0 +1,472 @@ +--- +phase: 21-chat-foundation +plan: 04 +type: execute +wave: 2 +depends_on: ["21-02"] +files_modified: + - ui/src/context/ChatPanelContext.tsx + - ui/src/components/ChatPanel.tsx + - ui/src/components/ChatInput.tsx + - ui/src/components/ChatMessage.tsx + - ui/src/main.tsx + - ui/src/components/Layout.tsx +autonomous: true +requirements: [INPUT-01, INPUT-07, THEME-01] + +must_haves: + truths: + - "A chat icon button in the Layout toggles the chat drawer open/closed" + - "Chat panel open state persists to localStorage under nexus:chat-panel-open" + - "Opening chat panel closes the PropertiesPanel" + - "Chat input auto-resizes as user types, up to max-height 160px" + - "Enter sends message, Shift+Enter inserts newline, Escape clears input" + - "Chat panel uses theme CSS variables (bg-background, bg-card, border-border)" + artifacts: + - path: "ui/src/context/ChatPanelContext.tsx" + provides: "ChatPanelProvider and useChatPanel hook" + exports: ["ChatPanelProvider", "useChatPanel"] + - path: "ui/src/components/ChatPanel.tsx" + provides: "Right-side chat drawer shell" + exports: ["ChatPanel"] + - path: "ui/src/components/ChatInput.tsx" + provides: "Auto-resize textarea with keyboard shortcuts" + exports: ["ChatInput"] + - path: "ui/src/components/ChatMessage.tsx" + provides: "Message wrapper for user vs assistant alignment" + exports: ["ChatMessage"] + key_links: + - from: "ui/src/components/Layout.tsx" + to: "ui/src/components/ChatPanel.tsx" + via: "ChatPanel rendered as sibling before PropertiesPanel" + pattern: " +Create the chat panel shell, context provider, input component, and Layout integration. + +Purpose: Wire the chat UI skeleton into the app — a toggle button in the Layout, a right-side drawer with open/close animation, and an auto-resizing input with keyboard shortcuts. This gives users a visible, interactive chat panel. +Output: Functional chat drawer that opens/closes, with working text input. + + + +@$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 +@.planning/phases/21-chat-foundation/21-02-SUMMARY.md + + +From ui/src/context/PanelContext.tsx: +```typescript +const STORAGE_KEY = "paperclip:panel-visible"; +interface PanelContextValue { + panelContent: ReactNode | null; + panelVisible: boolean; + openPanel: (content: ReactNode) => void; + closePanel: () => void; + setPanelVisible: (visible: boolean) => void; + togglePanelVisible: () => void; +} +export function PanelProvider({ children }: { children: ReactNode }): JSX.Element; +export function usePanel(): PanelContextValue; +``` + +From ui/src/components/Layout.tsx (line 416-435): +```tsx +
+
+ +
+ +
+``` + +From ui/src/main.tsx (line 50-51): +```tsx + + +``` + +From ui/src/components/ChatMarkdownMessage.tsx (created in Plan 02): +```typescript +export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }): JSX.Element; +``` +
+
+ + + + + Task 1: Create ChatPanelContext and ChatInput components + ui/src/context/ChatPanelContext.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatMessage.tsx + + - ui/src/context/PanelContext.tsx (mirror this pattern for localStorage persistence) + - ui/src/context/ThemeContext.tsx (context + hook export pattern) + - ui/src/components/MarkdownBody.tsx (understand existing markdown rendering approach) + + +**ChatPanelContext.tsx:** + +Create `ui/src/context/ChatPanelContext.tsx` mirroring the PanelContext pattern: + +```typescript +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; + +const STORAGE_KEY = "nexus:chat-panel-open"; + +interface ChatPanelContextValue { + chatOpen: boolean; + activeConversationId: string | null; + setChatOpen: (open: boolean) => void; + toggleChat: () => void; + setActiveConversationId: (id: string | null) => void; +} + +const ChatPanelContext = createContext(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(null); + + const setChatOpen = useCallback((open: boolean) => { + setChatOpenState(open); + writePreference(open); + }, []); + + const toggleChat = useCallback(() => { + setChatOpenState((prev) => { + const next = !prev; + writePreference(next); + return next; + }); + }, []); + + return ( + + {children} + + ); +} + +export function useChatPanel() { + const ctx = useContext(ChatPanelContext); + if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider"); + return ctx; +} +``` + +Key difference from PanelContext: default is `false` (chat starts closed), and uses `nexus:` prefix not `paperclip:`. + +**ChatInput.tsx:** + +Create `ui/src/components/ChatInput.tsx`: + +- A `
` wrapper with `onSubmit` that calls the provided `onSend(text)` callback +- `