docs(21-chat-foundation): create phase plan — 5 plans across 3 waves
This commit is contained in:
parent
4cfb052646
commit
4ee071ddb5
6 changed files with 1944 additions and 3 deletions
|
|
@ -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 | - |
|
||||
|
|
|
|||
303
.planning/phases/21-chat-foundation/21-01-PLAN.md
Normal file
303
.planning/phases/21-chat-foundation/21-01-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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 schema pattern to follow (from documents.ts) -->
|
||||
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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create Drizzle schema files and generate migration</name>
|
||||
<files>packages/db/src/schema/chat_conversations.ts, packages/db/src/schema/chat_messages.ts, packages/db/src/schema/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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`
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create shared types and Zod validators for chat</name>
|
||||
<files>packages/shared/src/types/chat.ts, packages/shared/src/validators/chat.ts, packages/shared/src/types/index.ts, packages/shared/src/validators/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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";
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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"`
|
||||
</acceptance_criteria>
|
||||
<done>Chat types (ChatConversation, ChatMessage, ChatConversationListItem) and Zod validators (createConversationSchema, updateConversationSchema, createMessageSchema) exist and are re-exported from the shared package barrel files.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- 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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-01-SUMMARY.md`
|
||||
</output>
|
||||
272
.planning/phases/21-chat-foundation/21-02-PLAN.md
Normal file
272
.planning/phases/21-chat-foundation/21-02-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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>
|
||||
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<Theme, { dark: boolean; label: string }>;
|
||||
export function useTheme(): { theme: Theme; toggleTheme: () => void };
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install rehype-highlight and add hljs theme CSS overrides</name>
|
||||
<files>ui/package.json, ui/src/index.css</files>
|
||||
<read_first>
|
||||
- ui/package.json (current dependencies)
|
||||
- ui/src/index.css (existing theme CSS variables and .dark/.theme-tokyo-night selectors)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ChatCodeBlock and ChatMarkdownMessage components</name>
|
||||
<files>ui/src/components/ChatCodeBlock.tsx, ui/src/components/ChatMarkdownMessage.tsx</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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 `<code>` 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 `<pre>` 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 (
|
||||
<div className={cn("paperclip-markdown", className)}>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
pre: ChatCodeBlock,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</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 `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`
|
||||
</output>
|
||||
358
.planning/phases/21-chat-foundation/21-03-PLAN.md
Normal file
358
.planning/phases/21-chat-foundation/21-03-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
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;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create chat service with CRUD operations</name>
|
||||
<files>server/src/services/chat.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create chat routes and mount in app.ts</name>
|
||||
<files>server/src/routes/chat.ts, server/src/app.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)`
|
||||
</acceptance_criteria>
|
||||
<done>Chat API routes are mounted on the Express app with 7 endpoints covering conversation CRUD and message CRUD, all gated by assertBoard auth.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`
|
||||
</output>
|
||||
472
.planning/phases/21-chat-foundation/21-04-PLAN.md
Normal file
472
.planning/phases/21-chat-foundation/21-04-PLAN.md
Normal file
|
|
@ -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: "<ChatPanel"
|
||||
- from: "ui/src/components/Layout.tsx"
|
||||
to: "ui/src/context/ChatPanelContext.tsx"
|
||||
via: "useChatPanel hook for toggle button"
|
||||
pattern: "useChatPanel"
|
||||
- from: "ui/src/main.tsx"
|
||||
to: "ui/src/context/ChatPanelContext.tsx"
|
||||
via: "ChatPanelProvider wrapping app"
|
||||
pattern: "<ChatPanelProvider"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
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
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main id="main-content" ... >
|
||||
<Outlet />
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
```
|
||||
|
||||
From ui/src/main.tsx (line 50-51):
|
||||
```tsx
|
||||
<PanelProvider>
|
||||
<DialogProvider>
|
||||
```
|
||||
|
||||
From ui/src/components/ChatMarkdownMessage.tsx (created in Plan 02):
|
||||
```typescript
|
||||
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }): JSX.Element;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create ChatPanelContext and ChatInput components</name>
|
||||
<files>ui/src/context/ChatPanelContext.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatMessage.tsx</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
**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<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, activeConversationId, setChatOpen, toggleChat, setActiveConversationId }}>
|
||||
{children}
|
||||
</ChatPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 `<form>` wrapper with `onSubmit` that calls the provided `onSend(text)` callback
|
||||
- `<textarea>` with:
|
||||
- `placeholder="Message your agent..."`
|
||||
- CSS: `field-sizing: content` for auto-resize (Chrome 123+, Firefox 129+)
|
||||
- Fallback: `useEffect` that sets `ref.current.style.height = "auto"; ref.current.style.height = ref.current.scrollHeight + "px"` on value change
|
||||
- `max-height: 160px` (10rem) with `overflow-y: auto` when exceeded
|
||||
- `className`: use shadcn textarea base classes (`flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ...`) plus `resize-none min-h-[40px] max-h-[160px]`
|
||||
- `rows={1}` initially
|
||||
- `onKeyDown` handler:
|
||||
- `e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing` → `e.preventDefault(); submit()`
|
||||
- `e.key === "Escape"` → clear textarea value, call `e.preventDefault()`
|
||||
- `Shift+Enter` → default behavior (inserts newline)
|
||||
- Send button: `Button variant="ghost" size="icon"` with `Send` icon from lucide-react
|
||||
- `disabled` when textarea is empty (after trim) or `isSubmitting` is true
|
||||
- `aria-label="Send message"`
|
||||
- When submitting: show `Loader2` icon with `animate-spin` class
|
||||
- Component props:
|
||||
```typescript
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**ChatMessage.tsx:**
|
||||
|
||||
Create `ui/src/components/ChatMessage.tsx`:
|
||||
|
||||
```typescript
|
||||
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ChatMessageProps {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content }: ChatMessageProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// assistant or system
|
||||
return (
|
||||
<div className="max-w-full">
|
||||
<ChatMarkdownMessage content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
User messages: right-aligned bubble with `bg-secondary`, plain text (no markdown).
|
||||
Assistant messages: left-aligned, full width, rendered via `ChatMarkdownMessage`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -q "nexus:chat-panel-open" ui/src/context/ChatPanelContext.tsx && grep -q "export function useChatPanel" ui/src/context/ChatPanelContext.tsx && grep -q "onSend" ui/src/components/ChatInput.tsx && grep -q "Shift" ui/src/components/ChatInput.tsx && grep -q "ChatMarkdownMessage" ui/src/components/ChatMessage.tsx && echo "OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/context/ChatPanelContext.tsx contains `const STORAGE_KEY = "nexus:chat-panel-open"`
|
||||
- ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel`
|
||||
- ChatPanelContext tracks `chatOpen`, `activeConversationId`, `setChatOpen`, `toggleChat`, `setActiveConversationId`
|
||||
- ui/src/components/ChatInput.tsx contains `e.key === "Enter"` with `!e.shiftKey` check
|
||||
- ui/src/components/ChatInput.tsx contains `e.key === "Escape"`
|
||||
- ui/src/components/ChatInput.tsx contains `field-sizing` or `scrollHeight` for auto-resize
|
||||
- ui/src/components/ChatInput.tsx contains `aria-label` with "Send message"
|
||||
- ui/src/components/ChatInput.tsx contains `max-h-[160px]` or equivalent max-height
|
||||
- ui/src/components/ChatMessage.tsx renders `ChatMarkdownMessage` for assistant role
|
||||
- ui/src/components/ChatMessage.tsx renders `bg-secondary` bubble for user role
|
||||
</acceptance_criteria>
|
||||
<done>ChatPanelContext provides open/close state with localStorage persistence. ChatInput auto-resizes and handles Enter/Shift+Enter/Escape keyboard shortcuts. ChatMessage renders user bubbles and assistant markdown.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ChatPanel shell and wire into Layout + main.tsx</name>
|
||||
<files>ui/src/components/ChatPanel.tsx, ui/src/components/Layout.tsx, ui/src/main.tsx</files>
|
||||
<read_first>
|
||||
- ui/src/components/Layout.tsx (full file — understand the flex row structure, PropertiesPanel placement, existing imports, and the mobile/desktop branching)
|
||||
- ui/src/main.tsx (full file — understand provider nesting order)
|
||||
- ui/src/components/PropertiesPanel.tsx (understand how it reads panelVisible, its width, and its rendering pattern)
|
||||
</read_first>
|
||||
<action>
|
||||
**ChatPanel.tsx:**
|
||||
|
||||
Create `ui/src/components/ChatPanel.tsx` — the right-side drawer shell:
|
||||
|
||||
```typescript
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function ChatPanel() {
|
||||
const { chatOpen, setChatOpen, activeConversationId } = useChatPanel();
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Chat"
|
||||
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
||||
style={{ width: chatOpen ? 380 : 0 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
||||
<span className="text-sm font-medium">Chat</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setChatOpen(false)}
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: conversation list (left) + thread (right) */}
|
||||
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
||||
{/* Left column: conversation list — placeholder for Plan 05 */}
|
||||
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
No conversations yet
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: message thread + input */}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
{/* Messages placeholder — wired in Plan 05 */}
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<ChatInput
|
||||
onSend={(content) => {
|
||||
// TODO: Wire to API in Plan 05
|
||||
console.log("send:", content);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key specs per UI-SPEC:
|
||||
- Width: 380px when open, 0 when closed
|
||||
- `transition-[width] duration-100 ease-out` matches sidebar
|
||||
- `hidden md:flex` — desktop only
|
||||
- Two-column: left 160px for conversation list, right flex-1 for messages+input
|
||||
- `min-w-[380px]` on inner elements prevents content collapsing during width animation
|
||||
|
||||
**Layout.tsx modifications:**
|
||||
|
||||
1. Add imports at top:
|
||||
```typescript
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
```
|
||||
|
||||
2. Inside the `Layout` function, add:
|
||||
```typescript
|
||||
const { chatOpen, toggleChat, setChatOpen } = useChatPanel();
|
||||
const { setPanelVisible } = usePanel();
|
||||
```
|
||||
|
||||
3. Add a `useEffect` that closes PropertiesPanel when chat opens (per UI-SPEC: "When ChatPanel opens, PropertiesPanel closes"):
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (chatOpen) {
|
||||
setPanelVisible(false);
|
||||
}
|
||||
}, [chatOpen, setPanelVisible]);
|
||||
```
|
||||
|
||||
4. Add a chat toggle button in the BreadcrumbBar area. Find where the theme toggle button and settings link are rendered (likely near the end of the BreadcrumbBar or in the Layout's top-right controls). Add a `MessageSquare` icon button BEFORE the theme toggle:
|
||||
```tsx
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden md:inline-flex h-7 w-7"
|
||||
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>
|
||||
```
|
||||
|
||||
5. Insert `<ChatPanel />` in the flex row BEFORE `<PropertiesPanel />`:
|
||||
Change:
|
||||
```tsx
|
||||
<main ...>
|
||||
<Outlet />
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
```
|
||||
To:
|
||||
```tsx
|
||||
<main ...>
|
||||
<Outlet />
|
||||
</main>
|
||||
<ChatPanel />
|
||||
<PropertiesPanel />
|
||||
```
|
||||
|
||||
**main.tsx modifications:**
|
||||
|
||||
Add `ChatPanelProvider` in the provider stack. Insert it as a sibling of `PanelProvider` — AFTER `PanelProvider` (since ChatPanel needs to call `setPanelVisible` from PanelContext):
|
||||
|
||||
```tsx
|
||||
import { ChatPanelProvider } from "./context/ChatPanelContext";
|
||||
```
|
||||
|
||||
In the render tree, wrap after PanelProvider:
|
||||
```tsx
|
||||
<PanelProvider>
|
||||
<ChatPanelProvider>
|
||||
<DialogProvider>
|
||||
...
|
||||
</DialogProvider>
|
||||
</ChatPanelProvider>
|
||||
</PanelProvider>
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -q "ChatPanel" ui/src/components/Layout.tsx && grep -q "MessageSquare" ui/src/components/Layout.tsx && grep -q "ChatPanelProvider" ui/src/main.tsx && grep -q "aria-label=\"Chat\"" ui/src/components/ChatPanel.tsx && grep -q "width: chatOpen ? 380 : 0" ui/src/components/ChatPanel.tsx && echo "OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatPanel.tsx contains `aria-label="Chat"` on the aside element
|
||||
- ui/src/components/ChatPanel.tsx contains `style={{ width: chatOpen ? 380 : 0 }}`
|
||||
- ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out`
|
||||
- ui/src/components/ChatPanel.tsx contains `hidden md:flex`
|
||||
- ui/src/components/Layout.tsx imports `ChatPanel` from "./ChatPanel"
|
||||
- ui/src/components/Layout.tsx imports `MessageSquare` from "lucide-react"
|
||||
- ui/src/components/Layout.tsx imports `useChatPanel` from "../context/ChatPanelContext"
|
||||
- ui/src/components/Layout.tsx renders `<ChatPanel />` before `<PropertiesPanel />`
|
||||
- ui/src/components/Layout.tsx contains `useEffect` that calls `setPanelVisible(false)` when `chatOpen` is true
|
||||
- ui/src/components/Layout.tsx contains a button with `aria-label` containing "chat" (case-insensitive)
|
||||
- ui/src/main.tsx imports `ChatPanelProvider`
|
||||
- ui/src/main.tsx contains `<ChatPanelProvider>` in the provider tree
|
||||
</acceptance_criteria>
|
||||
<done>ChatPanel renders as a 380px right-side drawer in Layout, toggled by a MessageSquare button. Opening chat closes PropertiesPanel. ChatPanelProvider is in the provider tree. The panel has a two-column skeleton ready for conversation list and message thread wiring.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
||||
- Chat panel open state persists in localStorage under "nexus:chat-panel-open"
|
||||
- Layout flex row: main + ChatPanel + PropertiesPanel
|
||||
- ChatInput handles Enter/Shift+Enter/Escape
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Chat toggle button visible in Layout controls area
|
||||
- Chat panel opens to 380px, closes to 0 with 100ms transition
|
||||
- PropertiesPanel closes when chat opens
|
||||
- Input auto-resizes, keyboard shortcuts work
|
||||
- All theme CSS variables used (no hardcoded colors)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-04-SUMMARY.md`
|
||||
</output>
|
||||
528
.planning/phases/21-chat-foundation/21-05-PLAN.md
Normal file
528
.planning/phases/21-chat-foundation/21-05-PLAN.md
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
---
|
||||
phase: 21-chat-foundation
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["21-03", "21-04"]
|
||||
files_modified:
|
||||
- ui/src/api/chat.ts
|
||||
- ui/src/hooks/useChatConversations.ts
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/components/ChatConversationList.tsx
|
||||
- ui/src/components/ChatConversationItem.tsx
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/components/ChatPanel.tsx
|
||||
autonomous: false
|
||||
|
||||
requirements: [HIST-02, HIST-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can create a new conversation via the + button"
|
||||
- "Conversation list shows all conversations sorted by most recent, with pinned at top"
|
||||
- "Clicking a conversation loads its messages into the thread pane"
|
||||
- "Sending a message posts to API, appends optimistically, and auto-scrolls"
|
||||
- "User can rename, pin, archive, and delete conversations from a dropdown menu"
|
||||
- "Scrolling to bottom of conversation list loads more conversations (infinite scroll)"
|
||||
- "Data survives page reload (read from server)"
|
||||
artifacts:
|
||||
- path: "ui/src/api/chat.ts"
|
||||
provides: "Chat API client functions"
|
||||
exports: ["chatApi"]
|
||||
- path: "ui/src/hooks/useChatConversations.ts"
|
||||
provides: "TanStack Query hook for conversation list with infinite scroll"
|
||||
exports: ["useChatConversations"]
|
||||
- path: "ui/src/hooks/useChatMessages.ts"
|
||||
provides: "TanStack Query hook for message list"
|
||||
exports: ["useChatMessages"]
|
||||
- path: "ui/src/components/ChatConversationList.tsx"
|
||||
provides: "Sidebar conversation list with infinite scroll"
|
||||
exports: ["ChatConversationList"]
|
||||
- path: "ui/src/components/ChatConversationItem.tsx"
|
||||
provides: "Single conversation row with action dropdown"
|
||||
exports: ["ChatConversationItem"]
|
||||
- path: "ui/src/components/ChatMessageList.tsx"
|
||||
provides: "Message thread with auto-scroll"
|
||||
exports: ["ChatMessageList"]
|
||||
key_links:
|
||||
- from: "ui/src/api/chat.ts"
|
||||
to: "server/src/routes/chat.ts"
|
||||
via: "fetch calls to /companies/:companyId/conversations and /conversations/:id/messages"
|
||||
pattern: "api\\.(get|post|patch|delete)"
|
||||
- from: "ui/src/hooks/useChatConversations.ts"
|
||||
to: "ui/src/api/chat.ts"
|
||||
via: "useInfiniteQuery calling chatApi.listConversations"
|
||||
pattern: "useInfiniteQuery"
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/components/ChatConversationList.tsx"
|
||||
via: "renders ChatConversationList in left column"
|
||||
pattern: "<ChatConversationList"
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/components/ChatMessageList.tsx"
|
||||
via: "renders ChatMessageList in right column"
|
||||
pattern: "<ChatMessageList"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the full chat UI: API client, TanStack Query hooks, conversation list with infinite scroll, message thread, and ChatPanel integration.
|
||||
|
||||
Purpose: Connect the UI shell (Plan 04) to the server API (Plan 03), enabling users to create conversations, send messages, and manage their conversation list. This is the integration plan that brings the chat feature to life.
|
||||
Output: Fully functional chat experience — create, read, update, delete conversations; send and view messages.
|
||||
</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-03-SUMMARY.md
|
||||
@.planning/phases/21-chat-foundation/21-04-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From ui/src/api/client.ts:
|
||||
```typescript
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
```
|
||||
|
||||
From packages/shared/src/types/chat.ts (created in Plan 01):
|
||||
```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; }
|
||||
```
|
||||
|
||||
From ui/src/context/ChatPanelContext.tsx (created in Plan 04):
|
||||
```typescript
|
||||
export function useChatPanel(): { chatOpen: boolean; activeConversationId: string | null; setChatOpen: (open: boolean) => void; toggleChat: () => void; setActiveConversationId: (id: string | null) => void; };
|
||||
```
|
||||
|
||||
From ui/src/context/CompanyContext.tsx:
|
||||
```typescript
|
||||
export function useCompany(): { selectedCompanyId: string | null; selectedCompany: Company | null; ... };
|
||||
```
|
||||
|
||||
From ui/src/components/ChatPanel.tsx (created in Plan 04):
|
||||
- Currently has placeholder conversation list and message thread
|
||||
- Has ChatInput wired with a console.log onSend
|
||||
|
||||
From ui/src/components/ChatMessage.tsx (created in Plan 04):
|
||||
```typescript
|
||||
export function ChatMessage({ role, content }: { role: "user" | "assistant" | "system"; content: string }): JSX.Element;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create chat API client and TanStack Query hooks</name>
|
||||
<files>ui/src/api/chat.ts, ui/src/hooks/useChatConversations.ts, ui/src/hooks/useChatMessages.ts</files>
|
||||
<read_first>
|
||||
- ui/src/api/client.ts (api.get, api.post, api.patch, api.delete patterns)
|
||||
- ui/src/api/activity.ts (reference for a simple API module pattern)
|
||||
- ui/src/hooks/useKeyboardShortcuts.ts (hook file pattern)
|
||||
- ui/src/lib/queryKeys.ts (if exists — check for existing query key patterns)
|
||||
</read_first>
|
||||
<action>
|
||||
**chat.ts API client:**
|
||||
|
||||
Create `ui/src/api/chat.ts`:
|
||||
|
||||
```typescript
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
ChatConversation,
|
||||
ChatConversationListResponse,
|
||||
ChatMessage,
|
||||
ChatMessageListResponse,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
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>(
|
||||
`/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
createConversation(companyId: string, data?: { title?: string; agentId?: string }) {
|
||||
return api.post<ChatConversation>(`/companies/${companyId}/conversations`, data ?? {});
|
||||
},
|
||||
|
||||
getConversation(id: string) {
|
||||
return api.get<ChatConversation>(`/conversations/${id}`);
|
||||
},
|
||||
|
||||
updateConversation(id: string, data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null }) {
|
||||
return api.patch<ChatConversation>(`/conversations/${id}`, data);
|
||||
},
|
||||
|
||||
deleteConversation(id: string) {
|
||||
return api.delete<void>(`/conversations/${id}`);
|
||||
},
|
||||
|
||||
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<ChatMessageListResponse>(
|
||||
`/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
postMessage(conversationId: string, data: { role: string; content: string; agentId?: string }) {
|
||||
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**useChatConversations.ts:**
|
||||
|
||||
Create `ui/src/hooks/useChatConversations.ts`:
|
||||
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
import type { ChatConversationListResponse } from "@paperclipai/shared";
|
||||
|
||||
export function useChatConversations(companyId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["chat", "conversations", companyId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: ChatConversationListResponse) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
|
||||
enabled: !!companyId,
|
||||
placeholderData: (prev) => prev, // keepPreviousData equivalent — prevents flicker (Pitfall 6)
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data?: { title?: string }) => chatApi.createConversation(companyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; title?: string; pinnedAt?: string | null; archivedAt?: string | null }) =>
|
||||
chatApi.updateConversation(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => chatApi.deleteConversation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
|
||||
},
|
||||
});
|
||||
|
||||
return { ...query, createMutation, updateMutation, deleteMutation };
|
||||
}
|
||||
```
|
||||
|
||||
**useChatMessages.ts:**
|
||||
|
||||
Create `ui/src/hooks/useChatMessages.ts`:
|
||||
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
import type { ChatMessage, ChatMessageListResponse } from "@paperclipai/shared";
|
||||
|
||||
export function useChatMessages(conversationId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["chat", "messages", conversationId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: ChatMessageListResponse) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (data: { content: string }) =>
|
||||
chatApi.postMessage(conversationId!, { role: "user", content: data.content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
|
||||
// Also invalidate conversations to update lastMessagePreview and sort order
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Flatten pages into a single sorted array (oldest first for display)
|
||||
const messages: ChatMessage[] = query.data?.pages.flatMap((p) => p.items).reverse() ?? [];
|
||||
|
||||
return { ...query, messages, sendMutation };
|
||||
}
|
||||
```
|
||||
|
||||
Note: Messages come from API in `desc(createdAt)` order (most recent first). Reversing gives chronological order for display.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -q "export const chatApi" ui/src/api/chat.ts && grep -q "useInfiniteQuery" ui/src/hooks/useChatConversations.ts && grep -q "useInfiniteQuery" ui/src/hooks/useChatMessages.ts && grep -q "sendMutation" ui/src/hooks/useChatMessages.ts && echo "OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/api/chat.ts exports `chatApi` object with methods: listConversations, createConversation, getConversation, updateConversation, deleteConversation, listMessages, postMessage
|
||||
- ui/src/hooks/useChatConversations.ts exports `useChatConversations` using `useInfiniteQuery`
|
||||
- ui/src/hooks/useChatConversations.ts contains `placeholderData` to prevent flicker
|
||||
- ui/src/hooks/useChatConversations.ts exports createMutation, updateMutation, deleteMutation
|
||||
- ui/src/hooks/useChatMessages.ts exports `useChatMessages` using `useInfiniteQuery`
|
||||
- ui/src/hooks/useChatMessages.ts exports `sendMutation` and `messages` (flattened+reversed)
|
||||
- Both hooks have `enabled: !!conversationId` or `enabled: !!companyId` guards
|
||||
</acceptance_criteria>
|
||||
<done>Chat API client provides 7 fetch methods. useChatConversations provides infinite scroll + CRUD mutations. useChatMessages provides paginated messages + send mutation with optimistic invalidation.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ChatConversationList, ChatConversationItem, ChatMessageList, and wire ChatPanel</name>
|
||||
<files>ui/src/components/ChatConversationList.tsx, ui/src/components/ChatConversationItem.tsx, ui/src/components/ChatMessageList.tsx, ui/src/components/ChatPanel.tsx</files>
|
||||
<read_first>
|
||||
- ui/src/components/ChatPanel.tsx (current placeholder state from Plan 04 — will be updated)
|
||||
- ui/src/components/ChatMessage.tsx (message rendering component from Plan 04)
|
||||
- ui/src/context/ChatPanelContext.tsx (useChatPanel hook — activeConversationId, setActiveConversationId)
|
||||
- ui/src/context/CompanyContext.tsx (useCompany for selectedCompanyId)
|
||||
- ui/src/components/ui/dropdown-menu.tsx (shadcn dropdown component for action menu)
|
||||
- ui/src/components/ui/skeleton.tsx (shadcn skeleton for loading state)
|
||||
- ui/src/components/ui/scroll-area.tsx (shadcn scroll area)
|
||||
- ui/src/components/ui/dialog.tsx (shadcn dialog for delete confirmation)
|
||||
</read_first>
|
||||
<action>
|
||||
**ChatConversationItem.tsx:**
|
||||
|
||||
Create `ui/src/components/ChatConversationItem.tsx`:
|
||||
|
||||
```typescript
|
||||
import type { ChatConversationListItem } from "@paperclipai/shared";
|
||||
```
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface ChatConversationItemProps {
|
||||
conversation: ChatConversationListItem;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onPin: (id: string, pinned: boolean) => void;
|
||||
onArchive: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Renders a row with:
|
||||
- Title text (truncated with `truncate` class), or "New Conversation" if title is null
|
||||
- Preview text below title: `lastMessagePreview` truncated, `text-xs text-muted-foreground truncate`
|
||||
- Active state: `bg-accent/60` when `isActive`, otherwise `hover:bg-accent`
|
||||
- On hover: reveal a `MoreHorizontal` icon button (lucide-react) that opens a `DropdownMenu` with items:
|
||||
- "Rename" — triggers inline rename (for simplicity in Phase 21, use `window.prompt("Rename conversation", currentTitle)` and call `onRename` — a proper inline editor can be added later)
|
||||
- "Pin" / "Unpin" — calls `onPin(id, !isPinned)` where `isPinned = !!conversation.pinnedAt`
|
||||
- "Archive" — calls `onArchive(id)`
|
||||
- "Delete" — calls `onDelete(id)` (the parent handles the confirmation dialog)
|
||||
- Pin indicator: if `conversation.pinnedAt`, show a small `Pin` icon (lucide-react, `h-3 w-3 text-muted-foreground`) before the title
|
||||
- Click on the row (outside dropdown) calls `onSelect(conversation.id)`
|
||||
|
||||
**ChatConversationList.tsx:**
|
||||
|
||||
Create `ui/src/components/ChatConversationList.tsx`:
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface ChatConversationListProps {
|
||||
companyId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses `useChatConversations(companyId)` hook
|
||||
- Renders a `ScrollArea` container
|
||||
- At the top: a "New conversation" button with `Plus` icon, `text-xs`, full width — calls `createMutation.mutateAsync()` then `setActiveConversationId(newConvo.id)`
|
||||
- Separate pinned conversations from unpinned: render pinned first (sorted by `pinnedAt`), then unpinned (sorted by `updatedAt`)
|
||||
- Map conversations to `<ChatConversationItem />` entries
|
||||
- Loading state: 5 `Skeleton` elements (`h-10 w-full rounded`)
|
||||
- Empty state: centered text "No conversations yet" / "Start a conversation to get help from your agents."
|
||||
- Infinite scroll: use an IntersectionObserver on a sentinel `<div>` at the bottom of the list. When it enters the viewport and `hasNextPage` is true, call `fetchNextPage()`
|
||||
- Delete confirmation: maintain a `deletingId` state. When set, render a shadcn `Dialog` with title "Delete conversation?", body "This conversation and all its messages will be permanently deleted.", and "Delete" (destructive) + "Keep conversation" (outline) buttons
|
||||
- Rename handler: `updateMutation.mutate({ id, title: newTitle })`
|
||||
- Pin handler: `updateMutation.mutate({ id, pinnedAt: pinned ? new Date().toISOString() : null })`
|
||||
- Archive handler: `updateMutation.mutate({ id, archivedAt: new Date().toISOString() })`
|
||||
- Delete handler: `deleteMutation.mutate(id)` then clear `deletingId` and if the deleted conversation was active, set `activeConversationId` to null
|
||||
|
||||
**ChatMessageList.tsx:**
|
||||
|
||||
Create `ui/src/components/ChatMessageList.tsx`:
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface ChatMessageListProps {
|
||||
conversationId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses `useChatMessages(conversationId)` hook
|
||||
- Renders messages in a container with `space-y-4`
|
||||
- Maps `messages` array (already chronological from the hook) to `<ChatMessage role={m.role} content={m.content} key={m.id} />`
|
||||
- Auto-scroll: use a `useRef` on a bottom sentinel div and `useEffect` that scrolls it into view when `messages.length` changes
|
||||
- Empty state: "Send a message to start this conversation." centered
|
||||
- Wrap in a `ScrollArea` or use a plain `div` with `overflow-auto flex-1`
|
||||
- The parent (`ChatPanel`) wraps this in the scroll region
|
||||
|
||||
**ChatPanel.tsx update:**
|
||||
|
||||
Replace the placeholder content in `ChatPanel.tsx` (from Plan 04) with the real components:
|
||||
|
||||
- Import `ChatConversationList`, `ChatMessageList`, `useCompany`, `useChatMessages`
|
||||
- Get `selectedCompanyId` from `useCompany()`
|
||||
- Get `activeConversationId`, `setActiveConversationId` from `useChatPanel()`
|
||||
- Wire `useChatMessages(activeConversationId)` for the send handler
|
||||
- Left column: `<ChatConversationList companyId={selectedCompanyId!} />` (guard: only render if `selectedCompanyId`)
|
||||
- Right column:
|
||||
- If `activeConversationId`: render `<ChatMessageList conversationId={activeConversationId} />`
|
||||
- If no `activeConversationId`: show empty state "Send a message to start this conversation."
|
||||
- Wire ChatInput's `onSend` to: if no activeConversationId, first create a conversation, then send message. If activeConversationId exists, just send message:
|
||||
```typescript
|
||||
const handleSend = async (content: string) => {
|
||||
let convId = activeConversationId;
|
||||
if (!convId) {
|
||||
const newConvo = await chatApi.createConversation(selectedCompanyId!, {});
|
||||
convId = newConvo.id;
|
||||
setActiveConversationId(convId);
|
||||
}
|
||||
await chatApi.postMessage(convId, { role: "user", content });
|
||||
// Invalidate queries
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", convId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
|
||||
};
|
||||
```
|
||||
Use `useMutation` or direct api calls with `useQueryClient` for invalidation.
|
||||
- Pass `isSubmitting` to ChatInput from the mutation state
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -q "ChatConversationList" ui/src/components/ChatConversationList.tsx && grep -q "ChatConversationItem" ui/src/components/ChatConversationItem.tsx && grep -q "ChatMessageList" ui/src/components/ChatMessageList.tsx && grep -q "ChatConversationList" ui/src/components/ChatPanel.tsx && grep -q "ChatMessageList" ui/src/components/ChatPanel.tsx && grep -q "postMessage" ui/src/components/ChatPanel.tsx && echo "OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatConversationList.tsx uses `useChatConversations` hook
|
||||
- ui/src/components/ChatConversationList.tsx renders `Plus` icon button for new conversation
|
||||
- ui/src/components/ChatConversationList.tsx has IntersectionObserver or sentinel div for infinite scroll
|
||||
- ui/src/components/ChatConversationList.tsx shows 5 Skeleton elements during loading
|
||||
- ui/src/components/ChatConversationList.tsx has delete confirmation Dialog with "Delete conversation?" title
|
||||
- ui/src/components/ChatConversationItem.tsx renders `DropdownMenu` with Rename, Pin/Unpin, Archive, Delete items
|
||||
- ui/src/components/ChatConversationItem.tsx applies `bg-accent/60` when `isActive`
|
||||
- ui/src/components/ChatMessageList.tsx uses `useChatMessages` hook
|
||||
- ui/src/components/ChatMessageList.tsx renders `ChatMessage` components
|
||||
- ui/src/components/ChatMessageList.tsx auto-scrolls to bottom on new messages
|
||||
- ui/src/components/ChatPanel.tsx renders `ChatConversationList` in the left column
|
||||
- ui/src/components/ChatPanel.tsx renders `ChatMessageList` when `activeConversationId` is set
|
||||
- ui/src/components/ChatPanel.tsx creates a conversation on first send if none active
|
||||
- ui/src/components/ChatPanel.tsx invalidates queries after sending a message
|
||||
</acceptance_criteria>
|
||||
<done>Full chat UI wired: conversation list with infinite scroll, CRUD actions (rename, pin, archive, delete with confirmation), message thread with auto-scroll, and send flow that creates conversations on first message.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Verify complete chat flow</name>
|
||||
<files>none</files>
|
||||
<action>
|
||||
Human verification checkpoint. No automated work — all implementation was completed in Tasks 1 and 2. The user follows the verification steps below to confirm the complete Phase 21 chat feature works end-to-end.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit && pnpm --filter @paperclipai/server exec -- tsc --noEmit && echo "TYPE CHECK OK"</automated>
|
||||
</verify>
|
||||
<read_first>
|
||||
- ui/src/components/ChatPanel.tsx
|
||||
- server/src/routes/chat.ts
|
||||
</read_first>
|
||||
<acceptance_criteria>
|
||||
- TypeScript compilation passes for both ui and server packages
|
||||
- User confirms: chat panel opens/closes from Layout toggle button
|
||||
- User confirms: conversations can be created, renamed, pinned, archived, deleted
|
||||
- User confirms: messages persist across page reload
|
||||
- User confirms: code blocks show syntax highlighting and copy button
|
||||
- User confirms: theme switch changes code block colors
|
||||
</acceptance_criteria>
|
||||
<what-built>
|
||||
Complete Phase 21 Chat Foundation: database persistence, server API, and full chat UI with conversation management, markdown rendering, syntax highlighting, and theme integration.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. Start the server: `cd /opt/nexus && pnpm dev`
|
||||
2. Open the app in a browser
|
||||
3. Click the MessageSquare (chat) icon in the top-right area — the chat panel should slide open from the right
|
||||
4. Click the "+" button to create a new conversation
|
||||
5. Type a message and press Enter — the message should appear as a right-aligned bubble
|
||||
6. Type a message with a code block:
|
||||
````
|
||||
Here is some code:
|
||||
```typescript
|
||||
const x: number = 42;
|
||||
console.log(x);
|
||||
```
|
||||
````
|
||||
Send it. The assistant message area will not auto-reply (no streaming in Phase 21), but you can manually POST an assistant message via curl to verify rendering:
|
||||
```bash
|
||||
curl -X POST http://localhost:3100/api/conversations/CONVERSATION_ID/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"role":"assistant","content":"Here is code:\n```typescript\nconst x: number = 42;\nconsole.log(x);\n```"}'
|
||||
```
|
||||
7. Verify the code block has:
|
||||
- Syntax highlighting (colors matching the active theme)
|
||||
- Language label ("typescript")
|
||||
- Copy button (hover over the code block)
|
||||
8. Switch themes (cycle button in top-right) — verify code block colors change
|
||||
9. Test conversation management:
|
||||
- Hover a conversation row, click "...", try Rename, Pin, Archive, Delete
|
||||
- Pin a conversation — verify it moves to the top
|
||||
- Delete a conversation — verify confirmation dialog appears
|
||||
10. Reload the page — verify conversations and messages persist
|
||||
11. Press Shift+Enter in the input — verify newline is inserted
|
||||
12. Press Escape in the input — verify content is cleared
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||
<done>User has verified the complete Phase 21 chat flow: panel toggle, conversation CRUD, message persistence, markdown rendering, syntax highlighting, theme integration, and keyboard shortcuts.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- All API endpoints respond correctly (conversation CRUD + message CRUD)
|
||||
- Conversation list uses infinite scroll (TanStack Query useInfiniteQuery)
|
||||
- Messages render with markdown + syntax highlighting
|
||||
- Theme switch updates code block colors
|
||||
- Data persists across page reload
|
||||
- Keyboard shortcuts work (Enter, Shift+Enter, Escape)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- User can create, rename, pin, archive, and delete conversations
|
||||
- User can send messages and see them in the thread
|
||||
- Code blocks in messages have syntax highlighting, language label, and copy button
|
||||
- Conversation list supports infinite scroll
|
||||
- All data persists in PostgreSQL across server restarts
|
||||
- Chat panel respects all three themes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-05-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue