[nexus] docs(21-chat-foundation): create phase plan
4 plans across 3 waves for Chat Foundation phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f14268682
commit
af211e6a39
5 changed files with 1684 additions and 2 deletions
|
|
@ -31,7 +31,12 @@
|
|||
3. Agent messages render with full markdown: code blocks with syntax highlighting and a copy button, tables, lists, headings, links, and inline images
|
||||
4. Conversations and all messages are stored in libSQL and survive a server restart
|
||||
5. The chat interface applies Catppuccin Mocha, Tokyo Night, and Catppuccin Latte themes correctly; code block highlighting matches the active theme
|
||||
**Plans**: TBD
|
||||
**Plans:** 4 plans
|
||||
Plans:
|
||||
- [ ] 21-01-PLAN.md — DB schema, shared types, service layer, and REST API for conversations and messages
|
||||
- [ ] 21-02-PLAN.md — ChatMarkdownMessage with syntax highlighting/copy button, ChatInput with auto-resize/keyboard shortcuts, theme CSS
|
||||
- [ ] 21-03-PLAN.md — Chat API client, panel context, hooks, ChatPanel/ConversationList/MessageList, Layout integration
|
||||
- [ ] 21-04-PLAN.md — Full test suite verification and visual/functional checkpoint
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 22: Agent Streaming
|
||||
|
|
@ -180,7 +185,7 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans.
|
|||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 21. Chat Foundation | v1.3 | 0/? | Not started | - |
|
||||
| 21. Chat Foundation | v1.3 | 0/4 | Planning complete | - |
|
||||
| 22. Agent Streaming | v1.3 | 0/? | Not started | - |
|
||||
| 23. Brainstormer Flow | v1.3 | 0/? | Not started | - |
|
||||
| 24. Search, History & Branching | v1.3 | 0/? | Not started | - |
|
||||
|
|
|
|||
512
.planning/phases/21-chat-foundation/21-01-PLAN.md
Normal file
512
.planning/phases/21-chat-foundation/21-01-PLAN.md
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
---
|
||||
phase: 21-chat-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/db/src/schema/chat_conversations.ts
|
||||
- packages/db/src/schema/chat_messages.ts
|
||||
- packages/db/src/schema/index.ts
|
||||
- packages/shared/src/types/chat.ts
|
||||
- packages/shared/src/validators/chat.ts
|
||||
- server/src/services/chat.ts
|
||||
- server/src/routes/chat.ts
|
||||
- server/src/routes/index.ts
|
||||
- server/src/app.ts
|
||||
- server/src/__tests__/chat-service.test.ts
|
||||
- server/src/__tests__/chat-routes.test.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- HIST-01
|
||||
- HIST-05
|
||||
- HIST-06
|
||||
- CHAT-04
|
||||
- CHAT-05
|
||||
- CHAT-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Conversations and messages are stored in PostgreSQL and survive server restarts"
|
||||
- "Multiple conversations exist per company, listed sorted by updatedAt DESC"
|
||||
- "First message on a conversation auto-generates a title from first 60 characters"
|
||||
- "Conversations can be soft-deleted, archived, and pinned via timestamp columns"
|
||||
- "Conversations are accessible from any device via the REST API"
|
||||
artifacts:
|
||||
- path: "packages/db/src/schema/chat_conversations.ts"
|
||||
provides: "chat_conversations Drizzle table definition"
|
||||
contains: "export const chatConversations"
|
||||
- path: "packages/db/src/schema/chat_messages.ts"
|
||||
provides: "chat_messages Drizzle table definition with cascade delete"
|
||||
contains: "onDelete: \"cascade\""
|
||||
- path: "packages/shared/src/types/chat.ts"
|
||||
provides: "ChatConversation and ChatMessage TypeScript interfaces"
|
||||
exports: ["ChatConversation", "ChatMessage"]
|
||||
- path: "packages/shared/src/validators/chat.ts"
|
||||
provides: "Zod schemas for create/update conversation and message"
|
||||
exports: ["createConversationSchema", "createMessageSchema", "updateConversationSchema"]
|
||||
- path: "server/src/services/chat.ts"
|
||||
provides: "chatService factory with CRUD operations"
|
||||
exports: ["chatService"]
|
||||
- path: "server/src/routes/chat.ts"
|
||||
provides: "chatRoutes factory mounting conversation and message endpoints"
|
||||
exports: ["chatRoutes"]
|
||||
- path: "server/src/__tests__/chat-service.test.ts"
|
||||
provides: "Service-level unit tests"
|
||||
min_lines: 80
|
||||
- path: "server/src/__tests__/chat-routes.test.ts"
|
||||
provides: "Route-level integration tests"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "server/src/routes/chat.ts"
|
||||
to: "server/src/services/chat.ts"
|
||||
via: "chatService(db) factory call"
|
||||
pattern: "chatService\\(db\\)"
|
||||
- from: "server/src/app.ts"
|
||||
to: "server/src/routes/chat.ts"
|
||||
via: "api.use(chatRoutes(db))"
|
||||
pattern: "chatRoutes\\(db\\)"
|
||||
- from: "packages/db/src/schema/index.ts"
|
||||
to: "packages/db/src/schema/chat_conversations.ts"
|
||||
via: "re-export"
|
||||
pattern: "export.*chatConversations.*chat_conversations"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the database schema, shared types, service layer, and REST API for chat conversations and messages.
|
||||
|
||||
Purpose: Establishes the persistence and API foundation that all UI components in subsequent plans depend on. Without this, no conversation can be created, stored, or retrieved.
|
||||
Output: Two new Drizzle schema files, a migration, shared types + validators, service + route factories, and automated tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/21-chat-foundation/21-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing patterns the executor must follow exactly -->
|
||||
|
||||
From packages/db/src/schema/documents.ts (reference pattern for new schema):
|
||||
```typescript
|
||||
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
// ... columns
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
From server/src/routes/activity.ts (reference pattern for route factory):
|
||||
```typescript
|
||||
export function activityRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = activityService(db);
|
||||
router.get("/companies/:companyId/activity", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, req.params.companyId!);
|
||||
const result = await svc.list(filters);
|
||||
res.json(result);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/__tests__/activity-routes.test.ts (reference test pattern):
|
||||
```typescript
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board", userId: "user-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/middleware/validate.ts:
|
||||
```typescript
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/app.ts (route mounting — line 158):
|
||||
```typescript
|
||||
api.use(activityRoutes(db));
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: DB schema, shared types, validators, and service with tests</name>
|
||||
<files>
|
||||
packages/db/src/schema/chat_conversations.ts,
|
||||
packages/db/src/schema/chat_messages.ts,
|
||||
packages/db/src/schema/index.ts,
|
||||
packages/shared/src/types/chat.ts,
|
||||
packages/shared/src/validators/chat.ts,
|
||||
server/src/services/chat.ts,
|
||||
server/src/__tests__/chat-service.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
packages/db/src/schema/documents.ts,
|
||||
packages/db/src/schema/index.ts,
|
||||
packages/db/src/schema/companies.ts,
|
||||
packages/db/src/schema/agents.ts,
|
||||
packages/shared/src/types/company.ts,
|
||||
packages/shared/src/validators/company.ts,
|
||||
server/src/services/documents.ts,
|
||||
server/src/__tests__/activity-routes.test.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: chatService(db).createConversation({ companyId, title }) inserts a row and returns it with id, companyId, title, createdAt, updatedAt
|
||||
- Test: chatService(db).listConversations(companyId, {}) returns items sorted by updatedAt DESC, excludes soft-deleted rows (deletedAt IS NOT NULL)
|
||||
- Test: chatService(db).listConversations with cursor returns only rows older than cursor
|
||||
- Test: chatService(db).listConversations returns hasMore=true when more rows exist beyond limit
|
||||
- Test: chatService(db).addMessage({ conversationId, role, content }) inserts message and bumps conversation.updatedAt
|
||||
- Test: chatService(db).addMessage on a conversation with title=null sets title to first 60 chars of content
|
||||
- Test: chatService(db).addMessage on a conversation with existing title does NOT overwrite title
|
||||
- Test: chatService(db).softDeleteConversation sets deletedAt timestamp
|
||||
- Test: chatService(db).archiveConversation sets archivedAt timestamp
|
||||
- Test: chatService(db).pinConversation sets pinnedAt timestamp
|
||||
- Test: chatService(db).unpinConversation clears pinnedAt to null
|
||||
- Test: chatService(db).updateConversation({ title }) updates title
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `packages/db/src/schema/chat_conversations.ts`:
|
||||
- Table name: `chat_conversations`
|
||||
- Columns: `id` (uuid PK defaultRandom), `companyId` (uuid FK to companies.id, NOT NULL), `title` (text, nullable), `agentId` (uuid FK to agents.id onDelete "set null", nullable), `pinnedAt` (timestamp with tz, nullable), `archivedAt` (timestamp with tz, nullable), `deletedAt` (timestamp with tz, nullable), `createdAt` (timestamp with tz, NOT NULL, defaultNow), `updatedAt` (timestamp with tz, NOT NULL, defaultNow)
|
||||
- Indexes: `chat_conversations_company_updated_idx` on (companyId, updatedAt), `chat_conversations_company_deleted_idx` on (companyId, deletedAt)
|
||||
- Export: `export const chatConversations`
|
||||
|
||||
2. Create `packages/db/src/schema/chat_messages.ts`:
|
||||
- Table name: `chat_messages`
|
||||
- Columns: `id` (uuid PK defaultRandom), `conversationId` (uuid FK to chatConversations.id onDelete "cascade", NOT NULL), `role` (text NOT NULL — values: "user" | "assistant" | "system"), `content` (text NOT NULL), `agentId` (uuid, nullable — which agent produced this), `createdAt` (timestamp with tz, NOT NULL, defaultNow)
|
||||
- Index: `chat_messages_conversation_created_idx` on (conversationId, createdAt)
|
||||
- Export: `export const chatMessages`
|
||||
|
||||
3. Add to `packages/db/src/schema/index.ts` — append two lines:
|
||||
```
|
||||
export { chatConversations } from "./chat_conversations.js";
|
||||
export { chatMessages } from "./chat_messages.js";
|
||||
```
|
||||
|
||||
4. Run `pnpm db:generate` to generate migration SQL. Verify the generated SQL contains:
|
||||
- `CREATE TABLE "chat_conversations"` with all columns
|
||||
- `CREATE TABLE "chat_messages"` with `ON DELETE CASCADE` on conversation_id FK
|
||||
- Both index `CREATE INDEX` statements
|
||||
|
||||
5. Create `packages/shared/src/types/chat.ts`:
|
||||
```typescript
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string | null;
|
||||
agentId: string | null;
|
||||
pinnedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatConversationListResponse {
|
||||
items: ChatConversation[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
6. Create `packages/shared/src/validators/chat.ts`:
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
export const createConversationSchema = z.object({
|
||||
title: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const updateConversationSchema = z.object({
|
||||
title: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const createMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system"]),
|
||||
content: z.string().min(1),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
```
|
||||
|
||||
7. Create `server/src/services/chat.ts` following the `documentService` factory pattern:
|
||||
```typescript
|
||||
export function chatService(db: Db) {
|
||||
return {
|
||||
async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) { ... },
|
||||
async createConversation(companyId: string, data: { title?: string }) { ... },
|
||||
async getConversation(id: string) { ... },
|
||||
async updateConversation(id: string, data: { title?: string }) { ... },
|
||||
async softDeleteConversation(id: string) { ... },
|
||||
async archiveConversation(id: string) { ... },
|
||||
async unarchiveConversation(id: string) { ... },
|
||||
async pinConversation(id: string) { ... },
|
||||
async unpinConversation(id: string) { ... },
|
||||
async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) { ... },
|
||||
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) { ... },
|
||||
};
|
||||
}
|
||||
```
|
||||
Key implementation details:
|
||||
- `listConversations`: filter `WHERE companyId = $1 AND deletedAt IS NULL`, order by `updatedAt DESC`, cursor-based pagination with `updatedAt < cursor`, limit defaults to 30 (max 100), return `{ items, hasMore }` where hasMore is `rows.length > limit` and items is `rows.slice(0, limit)`
|
||||
- `addMessage`: after inserting the message, run `UPDATE chat_conversations SET updated_at = now() WHERE id = $conversationId`. Also, if `conversation.title IS NULL`, set `title = content.slice(0, 60)` using `WHERE id = $conversationId AND title IS NULL` for idempotency
|
||||
- `softDeleteConversation`: `UPDATE chat_conversations SET deleted_at = now() WHERE id = $id`
|
||||
- `archiveConversation`: `UPDATE SET archived_at = now()`
|
||||
- `pinConversation`: `UPDATE SET pinned_at = now()`
|
||||
- `unpinConversation`: `UPDATE SET pinned_at = null`
|
||||
|
||||
8. Create `server/src/__tests__/chat-service.test.ts` using the `vi.mock` pattern from `activity-routes.test.ts`. Mock `@paperclipai/db` to provide a mock `db` object with chainable `.select().from().where().orderBy().limit()` and `.insert().values().returning()` and `.update().set().where()` methods. Test all behaviors listed in the `<behavior>` block above.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-service.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- packages/db/src/schema/chat_conversations.ts contains `export const chatConversations = pgTable("chat_conversations"`
|
||||
- packages/db/src/schema/chat_conversations.ts contains `pinnedAt: timestamp("pinned_at"` and `archivedAt: timestamp("archived_at"` and `deletedAt: timestamp("deleted_at"`
|
||||
- packages/db/src/schema/chat_messages.ts contains `onDelete: "cascade"`
|
||||
- packages/db/src/schema/chat_messages.ts contains `role: text("role").notNull()`
|
||||
- packages/db/src/schema/index.ts contains `export { chatConversations } from "./chat_conversations.js"`
|
||||
- packages/db/src/schema/index.ts contains `export { chatMessages } from "./chat_messages.js"`
|
||||
- packages/shared/src/types/chat.ts contains `export interface ChatConversation`
|
||||
- packages/shared/src/types/chat.ts contains `export interface ChatMessage`
|
||||
- packages/shared/src/validators/chat.ts contains `export const createConversationSchema`
|
||||
- packages/shared/src/validators/chat.ts contains `export const createMessageSchema`
|
||||
- server/src/services/chat.ts contains `export function chatService(db: Db)`
|
||||
- server/src/services/chat.ts contains `async addMessage(`
|
||||
- server/src/services/chat.ts contains `title IS NULL` or `isNull(chatConversations.title)` for idempotent title set
|
||||
- server/src/__tests__/chat-service.test.ts exits 0
|
||||
</acceptance_criteria>
|
||||
<done>All schema files, types, validators, and service exist. Service test suite passes with coverage of list, create, add message (with auto-title), soft-delete, archive, pin/unpin.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: REST API routes and route tests</name>
|
||||
<files>
|
||||
server/src/routes/chat.ts,
|
||||
server/src/routes/index.ts,
|
||||
server/src/app.ts,
|
||||
server/src/__tests__/chat-routes.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
server/src/routes/activity.ts,
|
||||
server/src/routes/index.ts,
|
||||
server/src/routes/authz.ts,
|
||||
server/src/app.ts,
|
||||
server/src/__tests__/activity-routes.test.ts,
|
||||
server/src/services/chat.ts,
|
||||
packages/shared/src/validators/chat.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: GET /api/companies/:companyId/conversations returns 200 with { items: [], hasMore: false } when empty
|
||||
- Test: POST /api/companies/:companyId/conversations returns 201 with the created conversation object
|
||||
- Test: GET /api/conversations/:id returns 200 with conversation object
|
||||
- Test: PATCH /api/conversations/:id with { title: "new title" } returns 200 with updated conversation
|
||||
- Test: DELETE /api/conversations/:id returns 204
|
||||
- Test: POST /api/conversations/:id/archive returns 200
|
||||
- Test: POST /api/conversations/:id/unarchive returns 200
|
||||
- Test: POST /api/conversations/:id/pin returns 200
|
||||
- Test: POST /api/conversations/:id/unpin returns 200
|
||||
- Test: GET /api/conversations/:id/messages returns 200 with { items: [], hasMore: false }
|
||||
- Test: POST /api/conversations/:id/messages with { role: "user", content: "hello" } returns 201
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `server/src/routes/chat.ts`:
|
||||
```typescript
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { chatService } from "../services/chat.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
|
||||
|
||||
export function chatRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = chatService(db);
|
||||
|
||||
// GET /api/companies/:companyId/conversations
|
||||
router.get("/companies/:companyId/conversations", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const result = await svc.listConversations(companyId, { cursor, limit });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/conversations
|
||||
router.post("/companies/:companyId/conversations", validate(createConversationSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const conversation = await svc.createConversation(companyId, req.body);
|
||||
res.status(201).json(conversation);
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id
|
||||
router.get("/conversations/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const conversation = await svc.getConversation(req.params.id as string);
|
||||
if (!conversation) { res.status(404).json({ error: "Not found" }); return; }
|
||||
res.json(conversation);
|
||||
});
|
||||
|
||||
// PATCH /api/conversations/:id
|
||||
router.patch("/conversations/:id", validate(updateConversationSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const conversation = await svc.updateConversation(req.params.id as string, req.body);
|
||||
res.json(conversation);
|
||||
});
|
||||
|
||||
// DELETE /api/conversations/:id
|
||||
router.delete("/conversations/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
await svc.softDeleteConversation(req.params.id as string);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/archive
|
||||
router.post("/conversations/:id/archive", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.archiveConversation(req.params.id as string);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/unarchive
|
||||
router.post("/conversations/:id/unarchive", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.unarchiveConversation(req.params.id as string);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/pin
|
||||
router.post("/conversations/:id/pin", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.pinConversation(req.params.id as string);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/unpin
|
||||
router.post("/conversations/:id/unpin", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.unpinConversation(req.params.id as string);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id/messages
|
||||
router.get("/conversations/:id/messages", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const result = await svc.listMessages(req.params.id as string, { cursor, limit });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/messages
|
||||
router.post("/conversations/:id/messages", validate(createMessageSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const message = await svc.addMessage(req.params.id as string, req.body);
|
||||
res.status(201).json(message);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
2. Add to `server/src/routes/index.ts`:
|
||||
```
|
||||
export { chatRoutes } from "./chat.js";
|
||||
```
|
||||
|
||||
3. In `server/src/app.ts`, add import `import { chatRoutes } from "./routes/chat.js";` near line 27 (after activityRoutes import), and add `api.use(chatRoutes(db));` after line 158 (after `api.use(activityRoutes(db));`).
|
||||
|
||||
4. Create `server/src/__tests__/chat-routes.test.ts` following the exact `activity-routes.test.ts` mock pattern:
|
||||
- Use `vi.hoisted` to create `mockChatService` with all methods as `vi.fn()`
|
||||
- `vi.mock("../services/chat.js", () => ({ chatService: () => mockChatService }))`
|
||||
- `createApp()` function that sets up express with JSON parsing, actor middleware (type: "board", companyIds: ["company-1"]), mounts `chatRoutes({} as any)` under `/api`, and adds `errorHandler`
|
||||
- Test all behaviors listed in `<behavior>` block
|
||||
- Also mock `@paperclipai/shared` validators if needed, or let them pass through (they are pure Zod schemas that work without mocking)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-routes.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- server/src/routes/chat.ts contains `export function chatRoutes(db: Db)`
|
||||
- server/src/routes/chat.ts contains `router.get("/companies/:companyId/conversations"`
|
||||
- server/src/routes/chat.ts contains `router.post("/conversations/:id/messages"`
|
||||
- server/src/routes/chat.ts contains `assertBoard(req)` on every mutating route
|
||||
- server/src/routes/chat.ts contains `assertCompanyAccess(req, companyId)` on the list endpoint
|
||||
- server/src/routes/index.ts contains `export { chatRoutes } from "./chat.js"`
|
||||
- server/src/app.ts contains `import { chatRoutes }` and `chatRoutes(db)`
|
||||
- server/src/__tests__/chat-routes.test.ts exits 0
|
||||
</acceptance_criteria>
|
||||
<done>All chat REST endpoints exist and respond with correct status codes. Route test suite passes. Routes are mounted in app.ts and exported from routes/index.ts.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm vitest run server/src/__tests__/chat-service.test.ts` passes
|
||||
- `pnpm vitest run server/src/__tests__/chat-routes.test.ts` passes
|
||||
- `pnpm db:generate` produces migration SQL with both tables and cascade FK
|
||||
- `grep -r "chatConversations\|chatMessages" packages/db/src/schema/index.ts` shows both exports
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Two new DB tables (chat_conversations, chat_messages) with correct columns, indexes, and cascade FK
|
||||
- Shared types and Zod validators for all create/update operations
|
||||
- Service layer with full CRUD + auto-title on first message + updatedAt bump
|
||||
- REST API with 11 endpoints mounted in app.ts
|
||||
- All automated tests green
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-01-SUMMARY.md`
|
||||
</output>
|
||||
483
.planning/phases/21-chat-foundation/21-02-PLAN.md
Normal file
483
.planning/phases/21-chat-foundation/21-02-PLAN.md
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
---
|
||||
phase: 21-chat-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ui/src/components/ChatMarkdownMessage.tsx
|
||||
- ui/src/components/ChatMarkdownMessage.test.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/components/ChatInput.test.tsx
|
||||
- ui/src/index.css
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CHAT-02
|
||||
- CHAT-03
|
||||
- INPUT-01
|
||||
- INPUT-07
|
||||
- THEME-01
|
||||
- THEME-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Agent messages render with full markdown: code blocks with syntax highlighting, tables, lists, headings, links, inline images"
|
||||
- "Code blocks have a one-click copy button and a language label"
|
||||
- "Code block syntax highlighting colors match the active theme (Catppuccin Mocha, Tokyo Night, Catppuccin Latte)"
|
||||
- "Chat input auto-resizes from 1 line up to 6 lines then scrolls internally"
|
||||
- "Enter sends, Shift+Enter inserts newline, Escape clears input or closes panel"
|
||||
- "Chat interface respects the Nexus theme system via CSS variables"
|
||||
artifacts:
|
||||
- path: "ui/src/components/ChatMarkdownMessage.tsx"
|
||||
provides: "Markdown message renderer with syntax highlighting and copy button"
|
||||
contains: "rehypeHighlight"
|
||||
- path: "ui/src/components/ChatInput.tsx"
|
||||
provides: "Auto-resize textarea with keyboard shortcuts"
|
||||
contains: "onKeyDown"
|
||||
- path: "ui/src/components/ChatMarkdownMessage.test.tsx"
|
||||
provides: "Tests for markdown rendering, code block copy button"
|
||||
min_lines: 30
|
||||
- path: "ui/src/components/ChatInput.test.tsx"
|
||||
provides: "Tests for keyboard shortcuts"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatMarkdownMessage.tsx"
|
||||
to: "ui/src/components/MarkdownBody.tsx"
|
||||
via: "extends MarkdownBody pattern with rehypeHighlight"
|
||||
pattern: "rehypeHighlight"
|
||||
- from: "ui/src/index.css"
|
||||
to: "highlight.js themes"
|
||||
via: "CSS overrides for .hljs per theme class"
|
||||
pattern: "\\.hljs"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the two core presentational components for chat: the markdown message renderer (with syntax highlighting and copy button) and the auto-resize text input (with keyboard shortcuts). Also install rehype-highlight and add theme-aware highlight.js CSS.
|
||||
|
||||
Purpose: These components are self-contained and have no dependency on the backend API. Building them in Wave 1 alongside Plan 01 maximizes parallelism.
|
||||
Output: Two tested React components ready to be composed into the ChatPanel in Plan 03.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/21-chat-foundation/21-RESEARCH.md
|
||||
@.planning/phases/21-chat-foundation/21-UI-SPEC.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing components and patterns the executor must reference -->
|
||||
|
||||
From ui/src/components/MarkdownBody.tsx:
|
||||
```typescript
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
}
|
||||
// Uses: <Markdown remarkPlugins={[remarkGfm]} components={components}>{content}</Markdown>
|
||||
```
|
||||
|
||||
From ui/src/context/ThemeContext.tsx:
|
||||
```typescript
|
||||
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
|
||||
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }>;
|
||||
// Theme classes on <html>: .dark (both dark themes), .theme-tokyo-night (tokyo-night only)
|
||||
// No class for catppuccin-mocha (it's the default dark), no class for catppuccin-latte (it's light)
|
||||
```
|
||||
|
||||
From ui/src/components/ui/textarea.tsx (shadcn Textarea):
|
||||
```typescript
|
||||
// Standard shadcn Textarea component, wraps <textarea> with cn() classNames
|
||||
```
|
||||
|
||||
From ui/src/lib/utils.ts:
|
||||
```typescript
|
||||
export function cn(...inputs: ClassValue[]) { ... }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install rehype-highlight and add theme-aware highlight.js CSS to index.css</name>
|
||||
<files>
|
||||
ui/src/index.css
|
||||
</files>
|
||||
<read_first>
|
||||
ui/package.json,
|
||||
ui/src/index.css,
|
||||
ui/src/context/ThemeContext.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
1. Run: `pnpm --filter @paperclipai/ui add rehype-highlight`
|
||||
This pulls in `rehype-highlight` (7.0.2) and `highlight.js` as a transitive dependency.
|
||||
|
||||
2. Verify installation: `ls node_modules/highlight.js/styles/base16/` should contain catppuccin theme files. `ls node_modules/highlight.js/styles/` should contain `tokyo-night-dark.css`.
|
||||
|
||||
3. Add theme-aware highlight.js CSS overrides to `ui/src/index.css`. Append AFTER the existing theme CSS variable blocks (after the last `}` of theme definitions, before any Tailwind utility layers). Do NOT import three separate CSS files — instead, define a single CSS block that maps `.hljs` variables per theme class:
|
||||
|
||||
```css
|
||||
/* ── highlight.js theme-aware overrides ───────────────────────── */
|
||||
@import "highlight.js/styles/base16/catppuccin-mocha.css" layer(hljs);
|
||||
|
||||
/*
|
||||
* The base import gives us catppuccin-mocha as default.
|
||||
* Override for tokyo-night and catppuccin-latte via specificity.
|
||||
*/
|
||||
.theme-tokyo-night .hljs {
|
||||
background: var(--card);
|
||||
color: #a9b1d6;
|
||||
}
|
||||
.theme-tokyo-night .hljs-keyword { color: #bb9af7; }
|
||||
.theme-tokyo-night .hljs-string { color: #9ece6a; }
|
||||
.theme-tokyo-night .hljs-number { color: #ff9e64; }
|
||||
.theme-tokyo-night .hljs-comment { color: #565f89; }
|
||||
.theme-tokyo-night .hljs-function,
|
||||
.theme-tokyo-night .hljs-title { color: #7aa2f7; }
|
||||
.theme-tokyo-night .hljs-built_in { color: #e0af68; }
|
||||
.theme-tokyo-night .hljs-type { color: #2ac3de; }
|
||||
.theme-tokyo-night .hljs-attr { color: #73daca; }
|
||||
.theme-tokyo-night .hljs-literal { color: #ff9e64; }
|
||||
.theme-tokyo-night .hljs-selector-class { color: #7aa2f7; }
|
||||
|
||||
:root:not(.dark) .hljs {
|
||||
background: var(--card);
|
||||
color: #4c4f69;
|
||||
}
|
||||
:root:not(.dark) .hljs-keyword { color: #8839ef; }
|
||||
:root:not(.dark) .hljs-string { color: #40a02b; }
|
||||
:root:not(.dark) .hljs-number { color: #fe640b; }
|
||||
:root:not(.dark) .hljs-comment { color: #9ca0b0; }
|
||||
:root:not(.dark) .hljs-function,
|
||||
:root:not(.dark) .hljs-title { color: #1e66f5; }
|
||||
:root:not(.dark) .hljs-built_in { color: #df8e1d; }
|
||||
:root:not(.dark) .hljs-type { color: #179299; }
|
||||
:root:not(.dark) .hljs-attr { color: #179299; }
|
||||
:root:not(.dark) .hljs-literal { color: #fe640b; }
|
||||
:root:not(.dark) .hljs-selector-class { color: #1e66f5; }
|
||||
```
|
||||
|
||||
This approach: imports catppuccin-mocha as the base layer (matches default dark theme), overrides for tokyo-night via `.theme-tokyo-night` class (which ThemeContext applies to `<html>`), and overrides for catppuccin-latte via `:root:not(.dark)` (light mode). Uses `var(--card)` for code block background so it integrates with the theme system. The `layer(hljs)` import keeps specificity manageable.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "rehype-highlight" ui/package.json && grep -c "\.hljs" ui/src/index.css</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/package.json contains "rehype-highlight" in dependencies
|
||||
- ui/src/index.css contains `.theme-tokyo-night .hljs`
|
||||
- ui/src/index.css contains `:root:not(.dark) .hljs`
|
||||
- ui/src/index.css contains `@import "highlight.js/styles/base16/catppuccin-mocha.css"`
|
||||
- ui/src/index.css does NOT contain three separate `@import` for highlight.js (only one base import)
|
||||
</acceptance_criteria>
|
||||
<done>rehype-highlight installed. Theme-aware highlight.js CSS added to index.css covering all three themes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: ChatMarkdownMessage component with syntax highlighting and copy button</name>
|
||||
<files>
|
||||
ui/src/components/ChatMarkdownMessage.tsx,
|
||||
ui/src/components/ChatMarkdownMessage.test.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/components/MarkdownBody.tsx,
|
||||
ui/src/context/ThemeContext.tsx,
|
||||
ui/src/lib/utils.ts,
|
||||
ui/src/index.css
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: renders markdown with headings, bold, italic, links, lists, tables
|
||||
- Test: renders code blocks with `language-` class from highlight.js
|
||||
- Test: code block container has a copy button with aria-label="Copy code"
|
||||
- Test: code block shows language label when language is specified (e.g. "typescript")
|
||||
- Test: inline code renders without copy button
|
||||
- Test: renders inline images with img tag
|
||||
</behavior>
|
||||
<action>
|
||||
Create `ui/src/components/ChatMarkdownMessage.tsx`:
|
||||
|
||||
```typescript
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ChatMarkdownMessageProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function extractText(node: ReactNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
||||
if (node && typeof node === "object" && "props" in node) {
|
||||
return extractText((node as any).props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function CodeBlock({ children, className, ...props }: { children?: ReactNode; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = className?.replace(/^language-/, "") ?? null;
|
||||
const codeText = extractText(children);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(codeText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [codeText]);
|
||||
|
||||
return (
|
||||
<div className="relative bg-card border border-border my-2">
|
||||
{language && (
|
||||
<span className="absolute top-2 left-3 text-xs text-muted-foreground select-none">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1.5 right-1.5 h-7 w-7"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<pre className={cn("overflow-x-auto p-4 pt-8 text-sm", className)} {...props}>
|
||||
<code className={className}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const components: Partial<Components> = {
|
||||
pre({ children, ...props }) {
|
||||
// Check if child is a <code> element with language class (code block)
|
||||
if (children && typeof children === "object" && "props" in (children as any)) {
|
||||
const childProps = (children as any).props;
|
||||
const childClassName = childProps?.className ?? "";
|
||||
return (
|
||||
<CodeBlock className={childClassName} {...props}>
|
||||
{childProps?.children}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
};
|
||||
|
||||
export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
|
||||
return (
|
||||
<div className={cn("prose prose-sm max-w-none dark:prose-invert", className)}>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `ui/src/components/ChatMarkdownMessage.test.tsx`:
|
||||
- Import `render, screen` from `@testing-library/react`
|
||||
- Test rendering markdown headings: `render(<ChatMarkdownMessage content="# Hello" />)`, expect `screen.getByRole("heading", { level: 1 })` to have text "Hello"
|
||||
- Test code block: `render(<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />)`, expect `screen.getByLabelText("Copy code")` to exist, expect language label "typescript" to be in the document
|
||||
- Test inline code does NOT have copy button: render with `\`inline\`` and verify no "Copy code" button
|
||||
- Test tables, lists, links render as expected HTML elements
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatMarkdownMessage.tsx contains `import rehypeHighlight from "rehype-highlight"`
|
||||
- ui/src/components/ChatMarkdownMessage.tsx contains `rehypePlugins={[rehypeHighlight]}`
|
||||
- ui/src/components/ChatMarkdownMessage.tsx contains `aria-label="Copy code"`
|
||||
- ui/src/components/ChatMarkdownMessage.tsx contains `navigator.clipboard.writeText`
|
||||
- ui/src/components/ChatMarkdownMessage.tsx exports `ChatMarkdownMessage`
|
||||
- ui/src/components/ChatMarkdownMessage.test.tsx exits 0
|
||||
</acceptance_criteria>
|
||||
<done>ChatMarkdownMessage renders full markdown with syntax-highlighted code blocks, language labels, and copy buttons. Test suite green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: ChatInput component with auto-resize and keyboard shortcuts</name>
|
||||
<files>
|
||||
ui/src/components/ChatInput.tsx,
|
||||
ui/src/components/ChatInput.test.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/components/ui/textarea.tsx,
|
||||
ui/src/lib/utils.ts,
|
||||
ui/src/components/MarkdownBody.tsx
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: Enter key (without Shift) calls onSend with current value and clears input
|
||||
- Test: Shift+Enter inserts a newline (does not call onSend)
|
||||
- Test: Escape clears input when input has content
|
||||
- Test: Escape calls onClose when input is empty
|
||||
- Test: Send button is disabled when input is empty
|
||||
- Test: Send button has aria-label="Send message"
|
||||
- Test: textarea has aria-label="Message input"
|
||||
- Test: input is disabled and send shows loader when isSubmitting=true
|
||||
</behavior>
|
||||
<action>
|
||||
Create `ui/src/components/ChatInput.tsx`:
|
||||
|
||||
```typescript
|
||||
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onClose?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onClose, isSubmitting = false, className }: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [value, isSubmitting, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSend, value, onClose],
|
||||
);
|
||||
|
||||
const isEmpty = value.trim().length === 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-end gap-2 border-t border-border p-3", className)}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
adjustHeight();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Send a message..."
|
||||
disabled={isSubmitting}
|
||||
aria-label="Message input"
|
||||
className={cn(
|
||||
"flex-1 resize-none bg-muted border border-border px-3 py-2 text-sm",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:ring-1 focus:ring-ring",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={{ minHeight: 40, maxHeight: 160, fieldSizing: "content" } as any}
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || isSubmitting}
|
||||
aria-label="Send message"
|
||||
aria-disabled={isEmpty || isSubmitting}
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `ui/src/components/ChatInput.test.tsx`:
|
||||
- Import `render, screen, fireEvent` from `@testing-library/react`
|
||||
- Test Enter sends: render with `onSend` spy, type "hello", fire Enter keydown (without shiftKey), assert `onSend` called with "hello"
|
||||
- Test Shift+Enter does not send: fire keydown with `key: "Enter", shiftKey: true`, assert `onSend` NOT called
|
||||
- Test Escape clears: type "hello", fire Escape, assert textarea value is empty
|
||||
- Test Escape calls onClose when empty: render with `onClose` spy, fire Escape on empty textarea, assert `onClose` called
|
||||
- Test disabled send button: render without typing, assert send button has `disabled` attribute
|
||||
- Test isSubmitting: render with `isSubmitting={true}`, assert textarea is disabled
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatInput.tsx contains `aria-label="Message input"`
|
||||
- ui/src/components/ChatInput.tsx contains `aria-label="Send message"`
|
||||
- ui/src/components/ChatInput.tsx contains `e.key === "Enter" && !e.shiftKey`
|
||||
- ui/src/components/ChatInput.tsx contains `e.key === "Escape"`
|
||||
- ui/src/components/ChatInput.tsx contains `onClose?.()`
|
||||
- ui/src/components/ChatInput.tsx contains `style={{ minHeight: 40, maxHeight: 160`
|
||||
- ui/src/components/ChatInput.tsx exports `ChatInput`
|
||||
- ui/src/components/ChatInput.test.tsx exits 0
|
||||
</acceptance_criteria>
|
||||
<done>ChatInput component handles auto-resize, Enter/Shift+Enter/Escape shortcuts, and submit/loading states. Test suite green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx` passes
|
||||
- `pnpm vitest run ui/src/components/ChatInput.test.tsx` passes
|
||||
- `grep "rehype-highlight" ui/package.json` returns a match
|
||||
- `grep ".hljs" ui/src/index.css` shows theme-aware overrides
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ChatMarkdownMessage renders markdown with GFM, syntax-highlighted code blocks, language labels, and copy buttons
|
||||
- ChatInput auto-resizes from 1 to 6 lines, sends on Enter, newline on Shift+Enter, clears/closes on Escape
|
||||
- All three themes have matching highlight.js CSS applied via class overrides
|
||||
- All component tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`
|
||||
</output>
|
||||
550
.planning/phases/21-chat-foundation/21-03-PLAN.md
Normal file
550
.planning/phases/21-chat-foundation/21-03-PLAN.md
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
---
|
||||
phase: 21-chat-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["21-01", "21-02"]
|
||||
files_modified:
|
||||
- ui/src/api/chat.ts
|
||||
- ui/src/context/ChatPanelContext.tsx
|
||||
- ui/src/hooks/useChatConversations.ts
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/components/ChatPanel.tsx
|
||||
- ui/src/components/ChatConversationList.tsx
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/components/Layout.tsx
|
||||
- ui/src/main.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- HIST-02
|
||||
- HIST-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can see a chat icon in the layout that toggles a right-side panel"
|
||||
- "User can create a new conversation and see it in the sidebar list"
|
||||
- "User can type a message, send it, and see it appear in the message list"
|
||||
- "Conversation list is sorted by most recent, loads more via infinite scroll"
|
||||
- "Opening chat panel closes the PropertiesPanel"
|
||||
- "Chat panel open state persists in localStorage across page loads"
|
||||
artifacts:
|
||||
- path: "ui/src/api/chat.ts"
|
||||
provides: "chatApi fetch wrappers for all endpoints"
|
||||
exports: ["chatApi"]
|
||||
- path: "ui/src/context/ChatPanelContext.tsx"
|
||||
provides: "ChatPanelProvider with open/close state and active conversation"
|
||||
exports: ["ChatPanelProvider", "useChatPanel"]
|
||||
- path: "ui/src/hooks/useChatConversations.ts"
|
||||
provides: "TanStack Query useInfiniteQuery wrapper for conversations"
|
||||
exports: ["useChatConversations"]
|
||||
- path: "ui/src/hooks/useChatMessages.ts"
|
||||
provides: "TanStack Query wrapper for messages"
|
||||
exports: ["useChatMessages"]
|
||||
- path: "ui/src/components/ChatPanel.tsx"
|
||||
provides: "Right-side drawer shell with conversation list and message area"
|
||||
contains: "role=\"complementary\""
|
||||
- path: "ui/src/components/ChatConversationList.tsx"
|
||||
provides: "Sidebar conversation list with infinite scroll, pin/archive/delete actions"
|
||||
contains: "IntersectionObserver"
|
||||
- path: "ui/src/components/ChatMessageList.tsx"
|
||||
provides: "Message thread rendering user and assistant messages"
|
||||
contains: "role=\"log\""
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/api/chat.ts"
|
||||
via: "useChatConversations and useChatMessages hooks"
|
||||
pattern: "useChatConversations|useChatMessages"
|
||||
- from: "ui/src/components/Layout.tsx"
|
||||
to: "ui/src/components/ChatPanel.tsx"
|
||||
via: "ChatPanel rendered in flex row, toggle via ChatPanelContext"
|
||||
pattern: "<ChatPanel"
|
||||
- from: "ui/src/components/Layout.tsx"
|
||||
to: "ui/src/context/ChatPanelContext.tsx"
|
||||
via: "useChatPanel to close PropertiesPanel when chat opens"
|
||||
pattern: "useChatPanel"
|
||||
- from: "ui/src/components/ChatConversationList.tsx"
|
||||
to: "ui/src/hooks/useChatConversations.ts"
|
||||
via: "useInfiniteQuery for paginated conversation list"
|
||||
pattern: "useChatConversations"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the chat UI together: API client, panel context, TanStack Query hooks, conversation list with infinite scroll, message list, and the chat panel drawer integrated into the Layout.
|
||||
|
||||
Purpose: This plan connects the backend (Plan 01) and presentational components (Plan 02) into a working end-to-end chat experience where users can create conversations, send messages, and browse history.
|
||||
Output: A fully functional chat panel accessible from the Layout, with conversation CRUD and message display.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/21-chat-foundation/21-RESEARCH.md
|
||||
@.planning/phases/21-chat-foundation/21-UI-SPEC.md
|
||||
@.planning/phases/21-chat-foundation/21-01-SUMMARY.md
|
||||
@.planning/phases/21-chat-foundation/21-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 outputs -->
|
||||
From packages/shared/src/types/chat.ts:
|
||||
```typescript
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string | null;
|
||||
agentId: string | null;
|
||||
pinnedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatConversationListResponse {
|
||||
items: ChatConversation[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
API endpoints (from Plan 01):
|
||||
- GET /api/companies/:companyId/conversations?cursor=&limit=
|
||||
- POST /api/companies/:companyId/conversations
|
||||
- GET /api/conversations/:id
|
||||
- PATCH /api/conversations/:id
|
||||
- DELETE /api/conversations/:id
|
||||
- POST /api/conversations/:id/archive
|
||||
- POST /api/conversations/:id/unarchive
|
||||
- POST /api/conversations/:id/pin
|
||||
- POST /api/conversations/:id/unpin
|
||||
- GET /api/conversations/:id/messages?cursor=&limit=
|
||||
- POST /api/conversations/:id/messages
|
||||
|
||||
<!-- From Plan 02 outputs -->
|
||||
From ui/src/components/ChatMarkdownMessage.tsx:
|
||||
```typescript
|
||||
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string })
|
||||
```
|
||||
|
||||
From ui/src/components/ChatInput.tsx:
|
||||
```typescript
|
||||
export function ChatInput({ onSend, onClose, isSubmitting, className }: {
|
||||
onSend: (content: string) => void;
|
||||
onClose?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
})
|
||||
```
|
||||
|
||||
<!-- Existing codebase interfaces -->
|
||||
From ui/src/api/client.ts:
|
||||
```typescript
|
||||
// api is an axios-like client or fetch wrapper — used as api.get("/path"), api.post("/path", body)
|
||||
```
|
||||
|
||||
From ui/src/context/PanelContext.tsx:
|
||||
```typescript
|
||||
export function usePanel(): {
|
||||
panelVisible: boolean;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
togglePanelVisible: () => void;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/context/CompanyContext.tsx:
|
||||
```typescript
|
||||
export function useCompany(): {
|
||||
selectedCompanyId: string | null;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/components/Layout.tsx (line 416):
|
||||
```tsx
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main id="main-content" ...>
|
||||
<Outlet />
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Chat API client, context provider, and TanStack Query hooks</name>
|
||||
<files>
|
||||
ui/src/api/chat.ts,
|
||||
ui/src/context/ChatPanelContext.tsx,
|
||||
ui/src/hooks/useChatConversations.ts,
|
||||
ui/src/hooks/useChatMessages.ts,
|
||||
ui/src/main.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/api/activity.ts,
|
||||
ui/src/api/client.ts,
|
||||
ui/src/context/PanelContext.tsx,
|
||||
ui/src/context/CompanyContext.tsx,
|
||||
ui/src/hooks/useKeyboardShortcuts.ts,
|
||||
ui/src/main.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
1. Create `ui/src/api/chat.ts` following the pattern from `ui/src/api/activity.ts`:
|
||||
```typescript
|
||||
import type { ChatConversation, ChatConversationListResponse, ChatMessage } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: (companyId: string, opts?: { cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.cursor) params.set("cursor", opts.cursor);
|
||||
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<ChatConversationListResponse>(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
createConversation: (companyId: string, data?: { title?: string }) =>
|
||||
api.post<ChatConversation>(`/api/companies/${companyId}/conversations`, data ?? {}),
|
||||
getConversation: (id: string) =>
|
||||
api.get<ChatConversation>(`/api/conversations/${id}`),
|
||||
updateConversation: (id: string, data: { title?: string }) =>
|
||||
api.patch<ChatConversation>(`/api/conversations/${id}`, data),
|
||||
deleteConversation: (id: string) =>
|
||||
api.delete(`/api/conversations/${id}`),
|
||||
archiveConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/archive`),
|
||||
unarchiveConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/unarchive`),
|
||||
pinConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/pin`),
|
||||
unpinConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/unpin`),
|
||||
listMessages: (conversationId: string, opts?: { cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.cursor) params.set("cursor", opts.cursor);
|
||||
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<{ items: ChatMessage[]; hasMore: boolean }>(`/api/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
sendMessage: (conversationId: string, data: { role: string; content: string; agentId?: string | null }) =>
|
||||
api.post<ChatMessage>(`/api/conversations/${conversationId}/messages`, data),
|
||||
};
|
||||
```
|
||||
|
||||
2. Create `ui/src/context/ChatPanelContext.tsx` following the `PanelContext.tsx` localStorage pattern:
|
||||
```typescript
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
const STORAGE_KEY = "nexus:chat-panel-open";
|
||||
|
||||
interface ChatPanelContextValue {
|
||||
chatOpen: boolean;
|
||||
setChatOpen: (open: boolean) => void;
|
||||
toggleChat: () => void;
|
||||
activeConversationId: string | null;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
|
||||
|
||||
function readPreference(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePreference(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(open));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function ChatPanelProvider({ children }: { children: ReactNode }) {
|
||||
const [chatOpen, setChatOpenState] = useState(readPreference);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
|
||||
const setChatOpen = useCallback((open: boolean) => {
|
||||
setChatOpenState(open);
|
||||
writePreference(open);
|
||||
}, []);
|
||||
|
||||
const toggleChat = useCallback(() => {
|
||||
setChatOpenState((prev) => {
|
||||
const next = !prev;
|
||||
writePreference(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatPanelContext.Provider
|
||||
value={{ chatOpen, setChatOpen, toggleChat, activeConversationId, setActiveConversationId }}
|
||||
>
|
||||
{children}
|
||||
</ChatPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatPanel() {
|
||||
const ctx = useContext(ChatPanelContext);
|
||||
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
3. Create `ui/src/hooks/useChatConversations.ts`:
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
|
||||
export function useChatConversations(companyId: string | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["chat", "conversations", companyId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
|
||||
enabled: !!companyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConversation(companyId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data?: { title?: string }) =>
|
||||
chatApi.createConversation(companyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConversationActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
pin: useMutation({
|
||||
mutationFn: chatApi.pinConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
unpin: useMutation({
|
||||
mutationFn: chatApi.unpinConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
archive: useMutation({
|
||||
mutationFn: chatApi.archiveConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
unarchive: useMutation({
|
||||
mutationFn: chatApi.unarchiveConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: chatApi.deleteConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
rename: useMutation({
|
||||
mutationFn: ({ id, title }: { id: string; title: string }) =>
|
||||
chatApi.updateConversation(id, { title }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
4. Create `ui/src/hooks/useChatMessages.ts`:
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
|
||||
export function useChatMessages(conversationId: string | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["chat", "messages", conversationId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage(conversationId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
chatApi.sendMessage(conversationId!, { role: "user", content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
5. Add `ChatPanelProvider` to `ui/src/main.tsx`: wrap the app tree with `<ChatPanelProvider>` as a sibling of the existing providers. Import from `"./context/ChatPanelContext"`. Place it INSIDE the existing `<QueryClientProvider>` but outside `<RouterProvider>` (or at the same level as other context providers).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "chatApi" ui/src/api/chat.ts && grep -c "useChatPanel" ui/src/context/ChatPanelContext.tsx && grep -c "ChatPanelProvider" ui/src/main.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/api/chat.ts exports `chatApi` with methods: listConversations, createConversation, getConversation, updateConversation, deleteConversation, archiveConversation, unarchiveConversation, pinConversation, unpinConversation, listMessages, sendMessage
|
||||
- ui/src/context/ChatPanelContext.tsx contains `localStorage.getItem(STORAGE_KEY)` with `STORAGE_KEY = "nexus:chat-panel-open"`
|
||||
- ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel`
|
||||
- ui/src/hooks/useChatConversations.ts contains `useInfiniteQuery` and `getNextPageParam`
|
||||
- ui/src/hooks/useChatMessages.ts contains `useInfiniteQuery`
|
||||
- ui/src/main.tsx contains `ChatPanelProvider`
|
||||
</acceptance_criteria>
|
||||
<done>Chat API client, context, and hooks are wired. ChatPanelProvider is in the app tree.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ChatPanel, ChatConversationList, ChatMessageList, and Layout integration</name>
|
||||
<files>
|
||||
ui/src/components/ChatPanel.tsx,
|
||||
ui/src/components/ChatConversationList.tsx,
|
||||
ui/src/components/ChatMessageList.tsx,
|
||||
ui/src/components/Layout.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/components/Layout.tsx,
|
||||
ui/src/components/PropertiesPanel.tsx,
|
||||
ui/src/context/PanelContext.tsx,
|
||||
ui/src/context/ChatPanelContext.tsx,
|
||||
ui/src/context/CompanyContext.tsx,
|
||||
ui/src/hooks/useChatConversations.ts,
|
||||
ui/src/hooks/useChatMessages.ts,
|
||||
ui/src/components/ChatMarkdownMessage.tsx,
|
||||
ui/src/components/ChatInput.tsx,
|
||||
ui/src/api/chat.ts,
|
||||
ui/src/components/ui/skeleton.tsx,
|
||||
ui/src/components/ui/dropdown-menu.tsx,
|
||||
ui/src/components/ui/scroll-area.tsx,
|
||||
ui/src/components/ui/tooltip.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
1. Create `ui/src/components/ChatConversationList.tsx`:
|
||||
A sidebar list of conversations with infinite scroll, inline actions, and inline rename.
|
||||
|
||||
Props: `{ companyId: string; activeId: string | null; onSelect: (id: string) => void; onNew: () => void }`
|
||||
|
||||
Implementation details:
|
||||
- Uses `useChatConversations(companyId)` for paginated data
|
||||
- Uses `useConversationActions()` for pin/archive/delete/rename mutations
|
||||
- Renders `<nav aria-label="Conversations">` containing a scrollable `<ScrollArea>` with list items
|
||||
- Each item: 48px height, `py-3 px-3` padding, `text-[13px]` title (truncated), `text-xs text-muted-foreground` timestamp right-aligned
|
||||
- Active item: `border-l-2 border-primary bg-sidebar-accent`
|
||||
- Hover: `bg-sidebar-accent/50` with a `<DropdownMenu>` trigger (MoreHorizontal icon) appearing on hover
|
||||
- DropdownMenu items: "Rename conversation", "Pin/Unpin conversation", "Archive/Unarchive conversation", "Delete conversation" (text-destructive)
|
||||
- Delete uses inline confirmation: when delete is clicked, replace the dropdown with a small popover showing "Delete this conversation?" with "Delete conversation" (variant="destructive") and "Keep conversation" (variant="ghost") buttons
|
||||
- Inline rename: double-click title or "Rename" from dropdown swaps title text with an `<input>` at 13px font, Enter/blur confirms, Escape cancels
|
||||
- Pinned conversations show filled Pin icon (14px, text-primary)
|
||||
- Infinite scroll: sentinel `<div ref={sentinelRef}>` at bottom, `IntersectionObserver` triggers `fetchNextPage()` when visible. While loading next page, show 2 `<Skeleton className="h-12 mx-3 my-1">` items
|
||||
- Loading state (initial): show 3 `<Skeleton>` items with `aria-busy="true"` on list container
|
||||
- Empty state: centered text "No conversations yet" (text-sm text-muted-foreground), "Start a conversation to get help with your work." body, "New conversation" button
|
||||
- Header: "Chat" title (text-base font-semibold), Plus icon button (tooltip "New conversation"), X icon button (close)
|
||||
|
||||
2. Create `ui/src/components/ChatMessageList.tsx`:
|
||||
Message thread for a single conversation.
|
||||
|
||||
Props: `{ conversationId: string }`
|
||||
|
||||
Implementation details:
|
||||
- Uses `useChatMessages(conversationId)` for data
|
||||
- Container: `<div role="log" aria-live="polite">` with `p-4 gap-4 flex flex-col`
|
||||
- User messages: right-aligned (`ml-auto`), `bg-secondary text-secondary-foreground`, `max-w-[75%]`, `px-4 py-2`, plain text (no markdown)
|
||||
- Assistant messages: left-aligned, no background, `max-w-[85%]`, rendered via `<ChatMarkdownMessage content={msg.content} />`
|
||||
- Timestamps: `text-xs text-muted-foreground`, visible on hover (`opacity-0 group-hover:opacity-100 transition-opacity`)
|
||||
- Auto-scroll to bottom when new messages arrive: `useEffect` with `scrollIntoView({ behavior: "smooth" })` on a bottom sentinel ref
|
||||
- Loading: single `<Skeleton>` block
|
||||
- Empty (no messages yet): light prompt text "Send a message to start the conversation."
|
||||
|
||||
3. Create `ui/src/components/ChatPanel.tsx`:
|
||||
The main right-side drawer shell that composes ChatConversationList, ChatMessageList, and ChatInput.
|
||||
|
||||
Implementation details:
|
||||
- Uses `useChatPanel()` for open/close state and activeConversationId
|
||||
- Uses `useCompany()` for selectedCompanyId
|
||||
- Uses `useCreateConversation(companyId)` for creating new conversations
|
||||
- Uses `useSendMessage(activeConversationId)` for sending messages
|
||||
- Outer div: `role="complementary" aria-label="Chat"`, width transition `transition-[width] duration-100 ease-out`, width: `chatOpen ? 380 : 0`, `overflow-hidden`, `border-l border-border`, `flex-shrink-0`
|
||||
- Internal layout when open: flex column, full height
|
||||
- Top: header bar (48px, `border-b border-border`, "Chat" heading, plus button, close button)
|
||||
- Middle: split horizontally — left side is `ChatConversationList` (240px wide, `bg-sidebar`, `border-r border-border`), right side is `ChatMessageList` (flex-1)
|
||||
- When no activeConversationId: show conversation list full-width and empty state
|
||||
- When activeConversationId set: show conversation list (240px) + message area
|
||||
- Bottom: `ChatInput` with `onSend` that calls `sendMessage.mutateAsync(content)`, `onClose` that calls `setChatOpen(false)`, `isSubmitting` bound to `sendMessage.isPending`
|
||||
- On "New conversation": call `createConversation.mutateAsync()`, set activeConversationId to the returned id, focus the ChatInput textarea
|
||||
- Focus management: when panel opens, focus ChatInput. When new conversation created, focus ChatInput.
|
||||
|
||||
4. Modify `ui/src/components/Layout.tsx`:
|
||||
- Add import: `import { ChatPanel } from "./ChatPanel";`
|
||||
- Add import: `import { useChatPanel } from "../context/ChatPanelContext";`
|
||||
- Add import: `import { MessageSquare } from "lucide-react";`
|
||||
- In the `Layout()` function body, add: `const { chatOpen, toggleChat, setChatOpen } = useChatPanel();`
|
||||
- Add effect: when `chatOpen` becomes true, call `setPanelVisible(false)` to close PropertiesPanel. This prevents both panels from competing for space.
|
||||
- In the flex row at line 416 (`<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>`), AFTER `<PropertiesPanel />` (line 434), add `<ChatPanel />`.
|
||||
- Add a chat toggle button in the top-right area of the layout (near the theme toggle button, around line 290-310). Use: `<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={toggleChat} aria-label={chatOpen ? "Close chat" : "Open chat"}><MessageSquare className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent></Tooltip>`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "ChatPanel" ui/src/components/Layout.tsx && grep -c "role=\"complementary\"" ui/src/components/ChatPanel.tsx && grep -c "IntersectionObserver" ui/src/components/ChatConversationList.tsx && grep -c "role=\"log\"" ui/src/components/ChatMessageList.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ui/src/components/ChatPanel.tsx contains `role="complementary"` and `aria-label="Chat"`
|
||||
- ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out`
|
||||
- ui/src/components/ChatPanel.tsx contains `chatOpen ? 380 : 0`
|
||||
- ui/src/components/ChatConversationList.tsx contains `<nav aria-label="Conversations"`
|
||||
- ui/src/components/ChatConversationList.tsx contains `IntersectionObserver`
|
||||
- ui/src/components/ChatConversationList.tsx contains `border-l-2 border-primary` for active state
|
||||
- ui/src/components/ChatConversationList.tsx contains `"Delete this conversation?"` confirmation text
|
||||
- ui/src/components/ChatConversationList.tsx contains `"No conversations yet"` empty state
|
||||
- ui/src/components/ChatMessageList.tsx contains `role="log"` and `aria-live="polite"`
|
||||
- ui/src/components/ChatMessageList.tsx contains `ChatMarkdownMessage`
|
||||
- ui/src/components/Layout.tsx contains `import { ChatPanel }` and `<ChatPanel />`
|
||||
- ui/src/components/Layout.tsx contains `useChatPanel`
|
||||
- ui/src/components/Layout.tsx contains `MessageSquare`
|
||||
- ui/src/components/Layout.tsx contains `setPanelVisible(false)` when chat opens
|
||||
</acceptance_criteria>
|
||||
<done>Chat panel is visible in Layout, conversation list shows with infinite scroll, messages render with markdown, input sends messages. Opening chat closes PropertiesPanel.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- App compiles without errors: `cd /Volumes/UsbNvme/repos/nexus && pnpm --filter @paperclipai/ui build` succeeds
|
||||
- ChatPanel renders in Layout with width transition
|
||||
- ChatConversationList uses IntersectionObserver for infinite scroll
|
||||
- ChatMessageList renders messages with ChatMarkdownMessage
|
||||
- Full test suite still passes: `pnpm test:run`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- User can open/close chat panel via MessageSquare button in Layout
|
||||
- User can create a new conversation via the plus button
|
||||
- User can send a message and see it in the message list
|
||||
- Conversation list shows sorted by most recent with infinite scroll
|
||||
- Pin/archive/delete/rename work from dropdown menu
|
||||
- Opening chat closes PropertiesPanel
|
||||
- Panel state persists in localStorage
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`
|
||||
</output>
|
||||
132
.planning/phases/21-chat-foundation/21-04-PLAN.md
Normal file
132
.planning/phases/21-chat-foundation/21-04-PLAN.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
phase: 21-chat-foundation
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["21-03"]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- CHAT-02
|
||||
- CHAT-03
|
||||
- CHAT-04
|
||||
- CHAT-05
|
||||
- CHAT-06
|
||||
- INPUT-01
|
||||
- INPUT-07
|
||||
- HIST-01
|
||||
- HIST-02
|
||||
- HIST-03
|
||||
- HIST-05
|
||||
- HIST-06
|
||||
- THEME-01
|
||||
- THEME-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All phase 21 success criteria verified visually by user"
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Visual and functional verification checkpoint for the complete Phase 21 chat foundation.
|
||||
|
||||
Purpose: Confirm the chat interface works end-to-end with correct theme integration, markdown rendering, and persistence before marking the phase complete.
|
||||
Output: User approval or list of issues to fix.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/21-chat-foundation/21-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Run full test suite and generate migration</name>
|
||||
<files></files>
|
||||
<read_first>
|
||||
server/src/__tests__/chat-service.test.ts,
|
||||
server/src/__tests__/chat-routes.test.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Run `pnpm db:generate` to generate the migration SQL if not already done. Verify generated SQL file exists under `packages/db/src/migrations/` and contains `CREATE TABLE "chat_conversations"` and `CREATE TABLE "chat_messages"` with `ON DELETE CASCADE`.
|
||||
|
||||
2. Run the full test suite: `pnpm test:run`
|
||||
All tests must pass, including the new chat-service and chat-routes tests.
|
||||
|
||||
3. Run `pnpm --filter @paperclipai/ui build` to verify the UI compiles cleanly with all new components.
|
||||
|
||||
4. If the dev server is not running, start it: `pnpm dev`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm test:run && pnpm --filter @paperclipai/ui build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm test:run` exits 0
|
||||
- `pnpm --filter @paperclipai/ui build` exits 0
|
||||
- Migration SQL file exists in packages/db/src/migrations/ containing "chat_conversations" and "chat_messages"
|
||||
</acceptance_criteria>
|
||||
<done>Full test suite green, UI builds clean, migration SQL generated.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Visual and functional verification of chat foundation</name>
|
||||
<files></files>
|
||||
<action>
|
||||
Present the running application to the user for visual verification of all Phase 21 requirements. Ensure the dev server is running at http://localhost:5173 and the database migration has been applied.
|
||||
</action>
|
||||
<verify>User provides "approved" signal</verify>
|
||||
<done>User confirms chat foundation works correctly.</done>
|
||||
<what-built>Complete chat foundation: right-side chat panel with conversation sidebar, markdown message rendering with syntax-highlighted code blocks and copy buttons, auto-resize input with keyboard shortcuts, theme-aware styling across all three themes, and persistent storage in PostgreSQL.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open the app at http://localhost:5173
|
||||
2. Click the chat icon (MessageSquare) in the top-right area of the layout — the chat panel should slide open from the right (380px wide)
|
||||
3. Verify the PropertiesPanel (if visible) closes when chat opens
|
||||
4. Click "New conversation" (Plus icon) — a conversation should appear in the sidebar list
|
||||
5. Type "Hello, this is a test message" and press Enter — the message should appear right-aligned in the message area
|
||||
6. Type a message with markdown: "Here is some **bold** and a code block:\n```typescript\nconst x: number = 42;\nconsole.log(x);\n```" and press Enter
|
||||
7. Verify the code block has:
|
||||
- Syntax highlighting (colored keywords)
|
||||
- A "typescript" language label in the top-left
|
||||
- A copy button in the top-right (click it — should show checkmark for 2 seconds)
|
||||
8. Switch themes: click the theme toggle button. For each theme (Catppuccin Mocha, Tokyo Night, Catppuccin Latte):
|
||||
- Verify the chat panel background matches the theme
|
||||
- Verify code block highlighting colors change with the theme
|
||||
- Verify text is readable and contrast is good
|
||||
9. Test keyboard shortcuts:
|
||||
- Shift+Enter in the input should create a newline (not send)
|
||||
- Enter should send the message
|
||||
- Escape with text in input should clear the input
|
||||
- Escape with empty input should close the chat panel
|
||||
10. Test conversation management: hover over a conversation in the sidebar, click the "..." menu:
|
||||
- Pin the conversation (should show pin icon)
|
||||
- Rename the conversation (should allow inline editing)
|
||||
- Archive the conversation (should disappear from list)
|
||||
- Create another conversation and delete it (should show "Delete this conversation?" confirmation)
|
||||
11. Reload the page — conversations and messages should persist
|
||||
12. Verify auto-resize: type multiple lines in the input — it should grow up to about 6 lines then scroll internally
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe any issues found</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Full visual and functional verification by user covering all 14 phase requirements.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
User approves the chat foundation as working correctly across all themes with proper markdown rendering, keyboard shortcuts, conversation CRUD, and persistence.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/21-chat-foundation/21-04-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue