diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f51e931d..80f3ef37 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -50,7 +50,12 @@ Plans: 4. User can click Stop to cancel an in-progress streaming response 5. User can edit a previous message to regenerate the response, or click Retry on any existing assistant message; conversations with 1,000+ messages scroll without jank via a virtualized list 6. Slash commands (`/brainstorm`, `/ask-pm`, `/ask-engineer`, `/task`, `/search`) route messages to the correct agent; `@mention` syntax routes to the named agent -**Plans**: TBD +**Plans:** 4 plans +Plans: +- [ ] 22-01-PLAN.md — DB migration (editedContent/editedAt), SSE stream endpoint, edit message route, agent selection, server tests +- [ ] 22-02-PLAN.md — Agent color utility, parseMessageIntent (slash/mention), ChatAgentBadge, AgentSelector, UI tests +- [ ] 22-03-PLAN.md — useStreamMessage hook, VList virtualization, ChatInput stop/popover, ChatPanel integration +- [ ] 22-04-PLAN.md — Full test suite verification and visual/functional checkpoint **UI hint**: yes ### Phase 23: Brainstormer Flow @@ -186,7 +191,7 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans. | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 21. Chat Foundation | v1.3 | 4/4 | Complete | 2026-04-01 | -| 22. Agent Streaming | v1.3 | 0/? | Not started | - | +| 22. Agent Streaming | v1.3 | 0/4 | Planned | - | | 23. Brainstormer Flow | v1.3 | 0/? | Not started | - | | 24. Search, History & Branching | v1.3 | 0/? | Not started | - | | 25. File System | v1.3 | 0/? | Not started | - | diff --git a/.planning/phases/22-agent-streaming/22-01-PLAN.md b/.planning/phases/22-agent-streaming/22-01-PLAN.md new file mode 100644 index 00000000..ab4f10a8 --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-01-PLAN.md @@ -0,0 +1,382 @@ +--- +phase: 22-agent-streaming +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - packages/db/src/schema/chat_messages.ts + - packages/db/src/migrations/TBD_agent_streaming.sql + - packages/shared/src/types/chat.ts + - packages/shared/src/validators/chat.ts + - server/src/services/chat.ts + - server/src/routes/chat.ts + - server/src/__tests__/chat-stream-routes.test.ts + - server/src/__tests__/chat-routes.test.ts +autonomous: true +requirements: [CHAT-01, CHAT-08, CHAT-10, CHAT-12, PERF-02] + +must_haves: + truths: + - "POST user message then GET /conversations/:id/stream returns text/event-stream with token events followed by a done event" + - "PATCH /conversations/:id accepts agentId field and persists it" + - "PUT /conversations/:id/messages/:messageId updates editedContent and editedAt" + - "SSE stream sets X-Accel-Buffering: no and flushes headers immediately for sub-100ms latency" + - "Client disconnect causes server to stop streaming (abort detection)" + artifacts: + - path: "server/src/routes/chat.ts" + provides: "SSE stream endpoint, edit message route, updateConversation with agentId" + exports: ["chatRoutes"] + - path: "server/src/services/chat.ts" + provides: "editMessage, getMessageHistory, updateConversationAgent" + exports: ["chatService"] + - path: "packages/db/src/schema/chat_messages.ts" + provides: "editedContent and editedAt columns" + contains: "editedContent" + - path: "packages/shared/src/types/chat.ts" + provides: "Updated ChatMessage with editedContent, editedAt" + contains: "editedContent" + - path: "packages/shared/src/validators/chat.ts" + provides: "streamMessageSchema, editMessageSchema, updateConversationSchema with agentId" + contains: "streamMessageSchema" + - path: "server/src/__tests__/chat-stream-routes.test.ts" + provides: "SSE streaming tests" + key_links: + - from: "server/src/routes/chat.ts" + to: "server/src/services/chat.ts" + via: "svc.addMessage, svc.editMessage, svc.getMessageHistory" + pattern: "svc\\.(addMessage|editMessage|getMessageHistory)" + - from: "server/src/routes/chat.ts" + to: "packages/shared/src/validators/chat.ts" + via: "validate(streamMessageSchema)" + pattern: "validate\\(streamMessageSchema\\)" +--- + + +Server-side streaming infrastructure: DB schema additions for message editing, SSE streaming endpoint for LLM token delivery, message edit route, agent selection on conversations, and server tests. + +Purpose: Establishes the entire server-side API surface that the UI plans (02/03) will consume. Every new endpoint is tested. +Output: Working SSE stream endpoint, edit message endpoint, conversation agent update, migration SQL, tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md + + + + +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; +} +``` + +From packages/shared/src/validators/chat.ts: +```typescript +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(), +}); +``` + +From packages/db/src/schema/chat_messages.ts: +```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 server/src/services/chat.ts: +```typescript +export function chatService(db: Db) { + // Returns object with: listConversations, createConversation, getConversation, + // updateConversation, softDeleteConversation, archiveConversation, unarchiveConversation, + // pinConversation, unpinConversation, listMessages, addMessage +} +``` + +From server/src/routes/chat.ts: +```typescript +export function chatRoutes(db: Db) { + // Mounts all routes on a Router. Key: PATCH /conversations/:id uses validate(updateConversationSchema) +} +``` + +SSE pattern from server/src/routes/plugins.ts:1146: +```typescript +res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +}); +res.flushHeaders(); +res.write(":ok\n\n"); +``` + + + + + + + Task 1: DB migration + shared types + validators + service methods for streaming and editing + + packages/db/src/schema/chat_messages.ts, + packages/shared/src/types/chat.ts, + packages/shared/src/validators/chat.ts, + server/src/services/chat.ts, + server/src/__tests__/chat-routes.test.ts + + + packages/db/src/schema/chat_messages.ts, + packages/db/src/schema/chat_conversations.ts, + packages/shared/src/types/chat.ts, + packages/shared/src/validators/chat.ts, + server/src/services/chat.ts, + server/src/__tests__/chat-routes.test.ts + + + - Test: editMessage(messageId, { content }) updates the message's editedContent and editedAt, returns updated row + - Test: getMessageHistory(conversationId) returns all messages in ascending createdAt order (for LLM context) + - Test: updateConversation with agentId field persists the agentId on the conversation + - Test: PATCH /conversations/:id with { agentId: "uuid" } returns 200 with updated conversation + - Test: PUT /conversations/:id/messages/:messageId with { content: "new" } returns 200 with editedContent set + + + 1. **DB schema** — Add two columns to `chatMessages` in `packages/db/src/schema/chat_messages.ts`: + ```typescript + editedContent: text("edited_content"), + editedAt: timestamp("edited_at", { withTimezone: true }), + ``` + Then run `pnpm db:generate` to create the migration SQL. + + 2. **Shared types** — Update `ChatMessage` interface in `packages/shared/src/types/chat.ts`: + - Add `editedContent: string | null;` + - Add `editedAt: string | null;` + + 3. **Validators** — In `packages/shared/src/validators/chat.ts`: + - Update `updateConversationSchema` to include `agentId: z.string().uuid().optional().nullable()` + - Add `export const editMessageSchema = z.object({ content: z.string().min(1) });` + - Add `export const streamMessageSchema = z.object({ content: z.string().min(1), agentId: z.string().uuid().optional().nullable() });` + + 4. **Service methods** — Add to `chatService` in `server/src/services/chat.ts`: + - `editMessage(messageId: string, data: { content: string })` — sets `editedContent = data.content`, `editedAt = new Date()` on the message row, returns the updated row + - `getMessageHistory(conversationId: string)` — selects all messages WHERE conversationId matches, ORDER BY createdAt ASC (ascending, for LLM context window). Returns `ChatMessage[]`. Use `editedContent ?? content` as the effective content field (alias as `effectiveContent` in the return). + - Update `updateConversation` to accept and persist `agentId` field: `set({ title: data.title, agentId: data.agentId, updatedAt: new Date() })`. Only set fields that are provided (check `data.agentId !== undefined` before including in set). + + 5. **Extend existing tests** in `server/src/__tests__/chat-routes.test.ts`: + - Add test: `PATCH /conversations/:id with agentId` — create conversation, PATCH with `{ agentId: someAgentId }`, verify response has the agentId set. (Use a dummy UUID string for agentId if the test DB doesn't enforce FK — check existing test patterns.) + - Add test: `PUT /conversations/:id/messages/:messageId` — create conversation, add message, PUT with `{ content: "edited" }`, verify response has `editedContent: "edited"` and `editedAt` is not null. + + + pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-routes + + + - grep -q "editedContent" packages/db/src/schema/chat_messages.ts returns 0 + - grep -q "editedAt" packages/db/src/schema/chat_messages.ts returns 0 + - grep -q "editedContent: string | null" packages/shared/src/types/chat.ts returns 0 + - grep -q "editMessageSchema" packages/shared/src/validators/chat.ts returns 0 + - grep -q "streamMessageSchema" packages/shared/src/validators/chat.ts returns 0 + - grep -q "agentId" packages/shared/src/validators/chat.ts (in updateConversationSchema) returns 0 + - grep -q "editMessage" server/src/services/chat.ts returns 0 + - grep -q "getMessageHistory" server/src/services/chat.ts returns 0 + - Migration SQL file exists in packages/db/src/migrations/ + - pnpm --filter @paperclipai/server test run -- chat-routes exits 0 + + DB has editedContent/editedAt columns, shared types updated, validators for stream/edit/agentId exist, service has editMessage + getMessageHistory, all tests pass + + + + Task 2: SSE streaming endpoint + edit message route + stream tests + + server/src/routes/chat.ts, + server/src/__tests__/chat-stream-routes.test.ts + + + server/src/routes/chat.ts, + server/src/routes/plugins.ts (lines 1095-1186 for SSE pattern), + server/src/services/chat.ts, + packages/shared/src/validators/chat.ts, + server/src/__tests__/chat-routes.test.ts + + + - Test: GET /conversations/:id/stream?triggerMessageId=X returns Content-Type text/event-stream + - Test: GET /conversations/:id/stream?triggerMessageId=X returns X-Accel-Buffering: no header + - Test: Stream sends initial `:ok` comment, then token events, then a done event + - Test: PUT /conversations/:id/messages/:messageId route validates body with editMessageSchema + - Test: Client close (req.destroy()) stops the stream loop + + + 1. **Edit message route** — Add to `server/src/routes/chat.ts`: + ```typescript + // PUT /conversations/:id/messages/:messageId + router.put("/conversations/:id/messages/:messageId", validate(editMessageSchema), async (req, res) => { + assertBoard(req); + const message = await svc.editMessage(req.params.messageId as string, req.body); + if (!message) { + res.status(404).json({ error: "Not found" }); + return; + } + res.json(message); + }); + ``` + + 2. **SSE stream endpoint** — Add to `server/src/routes/chat.ts`: + ```typescript + // GET /conversations/:id/stream + router.get("/conversations/:id/stream", async (req, res) => { + assertBoard(req); + const conversationId = req.params.id as string; + const triggerMessageId = req.query.triggerMessageId as string | undefined; + + const conversation = await svc.getConversation(conversationId); + if (!conversation) { + res.status(404).json({ error: "Not found" }); + return; + } + + // Set SSE headers — copied from plugins.ts:1146 + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + res.write(":ok\n\n"); + + let aborted = false; + req.on("close", () => { aborted = true; }); + + // Resolve the agent for this conversation + const agentId = conversation.agentId; + + // Get message history for LLM context + const history = await svc.getMessageHistory(conversationId); + + // For now: echo-stream mode. The actual LLM call will be wired when a provider + // is configured. This streams tokens from the last user message content one word + // at a time as a functional placeholder that fully exercises the SSE pipeline. + // Phase 23+ will replace this with real LLM calls via the agent's adapterConfig. + const lastUserMsg = history.filter(m => m.role === "user").at(-1); + const echoContent = lastUserMsg + ? `Echo from agent: ${lastUserMsg.content}` + : "No message to echo."; + const tokens = echoContent.split(/(\s+)/); + + let accumulated = ""; + for (const token of tokens) { + if (aborted) break; + accumulated += token; + res.write(`data: ${JSON.stringify({ type: "token", content: token })}\n\n`); + // Tiny yield to allow abort detection + await new Promise(resolve => setTimeout(resolve, 5)); + } + + // Persist assistant message only if stream completed (not aborted) + if (!aborted && accumulated.trim()) { + const assistantMsg = await svc.addMessage(conversationId, { + role: "assistant", + content: accumulated, + agentId, + }); + res.write(`data: ${JSON.stringify({ type: "done", messageId: assistantMsg.id })}\n\n`); + } else if (aborted) { + // Do NOT persist partial messages per RESEARCH.md pitfall 4 + } + + res.end(); + }); + ``` + + Import `editMessageSchema` and `streamMessageSchema` from `@paperclipai/shared` at the top of the routes file (alongside existing imports). + + 3. **Stream tests** — Create `server/src/__tests__/chat-stream-routes.test.ts`: + - Use the same test DB setup pattern as `chat-routes.test.ts` (read that file for the pattern). + - Test: `GET /conversations/:id/stream?triggerMessageId=X` — create conversation, add user message, open stream, collect all SSE data events, verify: + - Response status is 200 + - Content-Type header contains "text/event-stream" + - X-Accel-Buffering header is "no" + - First received data is `:ok` comment (or first data event has type "token") + - Last data event has `type: "done"` with a `messageId` string + - Test: `GET /conversations/:id/stream` for non-existent conversation returns 404 + - Test: After stream completes, a new assistant message exists in the DB (query via list messages) + - Test: `PUT /conversations/:id/messages/:messageId` with valid body returns 200 and editedContent matches + + For SSE testing: use supertest's `.buffer(true).parse(...)` or collect the raw response body. Alternatively, make a raw HTTP request to the test server and read the stream. Follow whatever pattern the existing test file uses for HTTP calls. + + 4. Add the `editMessageSchema` and `streamMessageSchema` imports to the routes file's import block from `@paperclipai/shared`. + + + pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-stream + + + - grep -q 'router.get("/conversations/:id/stream"' server/src/routes/chat.ts returns 0 + - grep -q 'router.put("/conversations/:id/messages/:messageId"' server/src/routes/chat.ts returns 0 + - grep -q "text/event-stream" server/src/routes/chat.ts returns 0 + - grep -q "X-Accel-Buffering" server/src/routes/chat.ts returns 0 + - grep -q "flushHeaders" server/src/routes/chat.ts returns 0 + - grep -q 'type: "done"' server/src/routes/chat.ts returns 0 + - grep -q 'type: "token"' server/src/routes/chat.ts returns 0 + - test -f server/src/__tests__/chat-stream-routes.test.ts + - pnpm --filter @paperclipai/server test run -- chat-stream exits 0 + - pnpm --filter @paperclipai/server test run exits 0 (all server tests green) + + SSE stream endpoint returns text/event-stream with token+done events, edit message route works, abort detection stops streaming, all server tests pass + + + + + +- `pnpm --filter @paperclipai/server test run` — all server tests pass +- `pnpm db:generate` has been run and migration exists +- SSE endpoint tested with token + done events +- Edit message route tested with editedContent persistence +- PATCH conversation with agentId tested + + + +1. New migration SQL exists and applies the editedContent + editedAt columns +2. GET /conversations/:id/stream returns text/event-stream with token events then done event +3. PUT /conversations/:id/messages/:messageId updates editedContent and editedAt +4. PATCH /conversations/:id with { agentId } persists the agent selection +5. All server tests pass (both chat-routes and chat-stream-routes) + + + +After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md` + diff --git a/.planning/phases/22-agent-streaming/22-02-PLAN.md b/.planning/phases/22-agent-streaming/22-02-PLAN.md new file mode 100644 index 00000000..7dc9610f --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-02-PLAN.md @@ -0,0 +1,328 @@ +--- +phase: 22-agent-streaming +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ui/src/lib/agent-colors.ts + - ui/src/lib/parseMessageIntent.ts + - ui/src/components/ChatAgentBadge.tsx + - ui/src/components/AgentSelector.tsx + - ui/src/components/ChatAgentBadge.test.tsx + - ui/src/components/ChatInput.slash-mention.test.tsx + - ui/src/lib/parseMessageIntent.test.ts +autonomous: true +requirements: [AGENT-04, THEME-03, INPUT-05, INPUT-06] + +must_haves: + truths: + - "ChatAgentBadge renders the agent name and a colored avatar circle based on agent role" + - "Agent avatar colors use --chart-1 through --chart-5 CSS variables, distinguishable across all three themes" + - "Slash commands /brainstorm, /ask-pm, /ask-engineer, /task, /search are parsed with correct target role" + - "@mention syntax @engineer resolves to target agent name" + - "Unknown / prefix passes through as plain text" + - "AgentSelector dropdown shows all agents and triggers onSelect callback" + artifacts: + - path: "ui/src/lib/agent-colors.ts" + provides: "agentRoleColorClass function mapping role to Tailwind class" + exports: ["agentRoleColorClass"] + - path: "ui/src/lib/parseMessageIntent.ts" + provides: "parseMessageIntent function for slash commands and @mentions" + exports: ["parseMessageIntent", "SLASH_COMMANDS"] + - path: "ui/src/components/ChatAgentBadge.tsx" + provides: "Agent badge with colored avatar + name" + exports: ["ChatAgentBadge"] + - path: "ui/src/components/AgentSelector.tsx" + provides: "Dropdown to select active agent per conversation" + exports: ["AgentSelector"] + key_links: + - from: "ui/src/components/ChatAgentBadge.tsx" + to: "ui/src/lib/agent-colors.ts" + via: "import { agentRoleColorClass }" + pattern: "agentRoleColorClass" + - from: "ui/src/components/AgentSelector.tsx" + to: "ui/src/lib/agent-colors.ts" + via: "import { agentRoleColorClass }" + pattern: "agentRoleColorClass" +--- + + +UI foundation components: agent color utility, ChatAgentBadge, AgentSelector dropdown, and slash command / @mention parsing logic with full test coverage. + +Purpose: Creates the presentational building blocks and pure parsing logic that Plan 03 (Wave 2) wires into the chat panel. All components are self-contained and testable without streaming infrastructure. +Output: 4 new files (agent-colors, parseMessageIntent, ChatAgentBadge, AgentSelector) + 3 test files. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md +@.planning/phases/22-agent-streaming/22-UI-SPEC.md + + + +From packages/shared/src/types/agent.ts: +```typescript +export interface Agent { + id: string; + companyId: string; + name: string; + urlKey: string; + role: AgentRole; // "ceo" | "pm" | "engineer" | "general" + title: string | null; + icon: string | null; + status: AgentStatus; + // ... other fields +} +``` + +From ui/src/api/agents.ts: +```typescript +export const agentsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/agents`), + // ... +}; +``` + +From ui/src/lib/queryKeys.ts: +```typescript +agents: { + list: (companyId: string) => ["agents", companyId] as const, + // ... +} +``` + +Existing agent pages use this pattern for fetching agents: +```typescript +const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, +}); +``` + +From ui/src/lib/agent-icons.ts (existing icon system): +```typescript +// Maps icon string names to lucide icon components +``` + +Shadcn components already installed (per UI-SPEC): Avatar, Badge, Select, Tooltip, Command, Popover + + + + + + + Task 1: Agent color utility + parseMessageIntent function + tests + + ui/src/lib/agent-colors.ts, + ui/src/lib/parseMessageIntent.ts, + ui/src/lib/parseMessageIntent.test.ts + + + ui/src/lib/agent-icons.ts, + ui/src/lib/utils.ts, + ui/src/index.css (search for --chart-1 through --chart-5 definitions), + .planning/phases/22-agent-streaming/22-UI-SPEC.md (Color section, agent role colors table), + .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 4: slash command parsing, Pattern 6: agent colors) + + + - Test: agentRoleColorClass("ceo") returns "bg-[hsl(var(--chart-1))]" + - Test: agentRoleColorClass("pm") returns "bg-[hsl(var(--chart-2))]" + - Test: agentRoleColorClass("engineer") returns "bg-[hsl(var(--chart-3))]" + - Test: agentRoleColorClass("general") returns "bg-[hsl(var(--chart-4))]" + - Test: agentRoleColorClass("brainstormer") returns "bg-[hsl(var(--chart-5))]" + - Test: agentRoleColorClass("unknown") returns "bg-muted" + - Test: parseMessageIntent("/brainstorm Hello") returns { text: "Hello", targetRole: "brainstormer" } + - Test: parseMessageIntent("/ask-pm Can you review?") returns { text: "Can you review?", targetRole: "pm" } + - Test: parseMessageIntent("/ask-engineer Fix the bug") returns { text: "Fix the bug", targetRole: "engineer" } + - Test: parseMessageIntent("/task Create login page") returns { text: "Create login page", targetRole: "engineer" } + - Test: parseMessageIntent("/search old messages") returns { text: "old messages", targetRole: "generalist" } + - Test: parseMessageIntent("@engineer Hello") returns { text: "Hello", targetName: "engineer" } + - Test: parseMessageIntent("@PM-agent Check this") returns { text: "Check this", targetName: "pm-agent" } + - Test: parseMessageIntent("/unknown-command Hello") returns { text: "/unknown-command Hello" } (no targetRole) + - Test: parseMessageIntent("Just a normal message") returns { text: "Just a normal message" } + - Test: parseMessageIntent("/path/to/file.ts") returns { text: "/path/to/file.ts" } (no targetRole — not followed by space) + + + 1. **Create `ui/src/lib/agent-colors.ts`:** + ```typescript + const ROLE_COLOR_CLASS: Record = { + ceo: "bg-[hsl(var(--chart-1))]", + pm: "bg-[hsl(var(--chart-2))]", + engineer: "bg-[hsl(var(--chart-3))]", + general: "bg-[hsl(var(--chart-4))]", + generalist: "bg-[hsl(var(--chart-4))]", + brainstormer: "bg-[hsl(var(--chart-5))]", + }; + + export function agentRoleColorClass(role: string): string { + return ROLE_COLOR_CLASS[role] ?? "bg-muted"; + } + ``` + + 2. **Create `ui/src/lib/parseMessageIntent.ts`:** + ```typescript + export const SLASH_COMMANDS: Record = { + "/brainstorm": "brainstormer", + "/ask-pm": "pm", + "/ask-engineer": "engineer", + "/task": "engineer", + "/search": "generalist", + }; + + export interface MessageIntent { + text: string; + targetRole?: string; + targetName?: string; + } + + export function parseMessageIntent(content: string): MessageIntent { + const trimmed = content.trim(); + + // Slash command: must match known command followed by whitespace or end-of-string + for (const [cmd, role] of Object.entries(SLASH_COMMANDS)) { + if (trimmed.toLowerCase().startsWith(cmd)) { + const rest = trimmed.slice(cmd.length); + // Only match if followed by whitespace or end-of-string (not /path/to/file) + if (rest.length === 0 || /^\s/.test(rest)) { + return { text: rest.trim() || "", targetRole: role }; + } + } + } + + // @mention: @word followed by whitespace then content + const mentionMatch = trimmed.match(/^@([\w][\w-]*)\s+([\s\S]*)/); + if (mentionMatch) { + return { text: mentionMatch[2]!.trim(), targetName: mentionMatch[1]!.toLowerCase() }; + } + + return { text: trimmed }; + } + ``` + + 3. **Create `ui/src/lib/parseMessageIntent.test.ts`:** Write Vitest tests covering all the behaviors listed above. Use `describe("parseMessageIntent", () => { ... })` and `describe("agentRoleColorClass", () => { ... })` blocks. Import from the respective modules. + + + pnpm --filter @paperclipai/ui test run -- --reporter=verbose parseMessageIntent + + + - test -f ui/src/lib/agent-colors.ts + - test -f ui/src/lib/parseMessageIntent.ts + - test -f ui/src/lib/parseMessageIntent.test.ts + - grep -q "agentRoleColorClass" ui/src/lib/agent-colors.ts returns 0 + - grep -q "parseMessageIntent" ui/src/lib/parseMessageIntent.ts returns 0 + - grep -q "SLASH_COMMANDS" ui/src/lib/parseMessageIntent.ts returns 0 + - grep -q "/brainstorm" ui/src/lib/parseMessageIntent.ts returns 0 + - grep -q "@mention" ui/src/lib/parseMessageIntent.ts OR grep -q "targetName" returns 0 + - pnpm --filter @paperclipai/ui test run -- parseMessageIntent exits 0 + + Agent color mapping utility and message intent parsing with slash commands + @mentions both implemented and fully tested + + + + Task 2: ChatAgentBadge + AgentSelector components + tests + + ui/src/components/ChatAgentBadge.tsx, + ui/src/components/ChatAgentBadge.test.tsx, + ui/src/components/AgentSelector.tsx + + + ui/src/lib/agent-colors.ts (just created in Task 1), + ui/src/lib/agent-icons.ts, + ui/src/components/ui/select.tsx, + ui/src/components/ui/avatar.tsx, + ui/src/components/ui/tooltip.tsx, + ui/src/components/ui/skeleton.tsx, + .planning/phases/22-agent-streaming/22-UI-SPEC.md (ChatAgentBadge and AgentSelector specs) + + + 1. **Create `ui/src/components/ChatAgentBadge.tsx`:** + + Props: `{ agentId: string | null; agents: Agent[] }` where `Agent` is from `@paperclipai/shared`. + + Resolve the agent from the `agents` array by matching `agent.id === agentId`. If not found or `agentId` is null, show fallback. + + Layout per UI-SPEC: + - Container: `flex items-center gap-2 mb-1` + - Avatar circle: `w-5 h-5 rounded-full flex items-center justify-center` + `agentRoleColorClass(agent.role)` background + `text-white` + - If agent has an `icon` value: use the `AgentIcon` component at 12px (check how `agent-icons.ts` maps icon strings to lucide components — read that file). If no `AgentIcon` component exists, render the lucide `Bot` icon at 12px. + - If no icon: render first letter of `agent.name` at `text-[10px] font-semibold text-white` + - Agent name: `` + - Fallback (agent not found): `Bot` icon (12px) + "Agent" text, `bg-muted` background + - Avatar element: `aria-hidden="true"` (decorative per accessibility contract) + + 2. **Create `ui/src/components/ChatAgentBadge.test.tsx`:** + Use jsdom + createRoot + act pattern (same as `ChatInput.test.tsx` — read that file for the testing pattern). NOT `@testing-library/react`. + + Tests: + - Renders agent name when agentId matches an agent in the array + - Renders "Agent" when agentId is null + - Renders "Agent" when agentId does not match any agent in the array + - Avatar has aria-hidden="true" + - Agent name span has aria-label containing agent name + + 3. **Create `ui/src/components/AgentSelector.tsx`:** + + Props: `{ agents: Agent[]; currentAgentId: string | null; onSelect: (agentId: string) => void; isLoading?: boolean }` + + Implementation per UI-SPEC: + - Use shadcn `