[nexus] docs(22-agent-streaming): create phase plan

4 plans across 3 waves for SSE streaming, agent selector, edit/retry,
slash commands, @mentions, and virtualized message list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-01 14:34:00 +02:00
parent 2f60c16d8d
commit 1b64970e90
5 changed files with 1650 additions and 2 deletions

View file

@ -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 | - |

View file

@ -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\\)"
---
<objective>
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.
</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/22-agent-streaming/22-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
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");
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: DB migration + shared types + validators + service methods for streaming and editing</name>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-routes</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>DB has editedContent/editedAt columns, shared types updated, validators for stream/edit/agentId exist, service has editMessage + getMessageHistory, all tests pass</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: SSE streaming endpoint + edit message route + stream tests</name>
<files>
server/src/routes/chat.ts,
server/src/__tests__/chat-stream-routes.test.ts
</files>
<read_first>
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
</read_first>
<behavior>
- 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
</behavior>
<action>
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`.
</action>
<verify>
<automated>pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-stream</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>SSE stream endpoint returns text/event-stream with token+done events, edit message route works, abort detection stops streaming, all server tests pass</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
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)
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</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/22-agent-streaming/22-RESEARCH.md
@.planning/phases/22-agent-streaming/22-UI-SPEC.md
<interfaces>
<!-- Agent type from shared package -->
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<Agent[]>(`/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
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Agent color utility + parseMessageIntent function + tests</name>
<files>
ui/src/lib/agent-colors.ts,
ui/src/lib/parseMessageIntent.ts,
ui/src/lib/parseMessageIntent.test.ts
</files>
<read_first>
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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
1. **Create `ui/src/lib/agent-colors.ts`:**
```typescript
const ROLE_COLOR_CLASS: Record<string, string> = {
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<string, string> = {
"/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.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui test run -- --reporter=verbose parseMessageIntent</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Agent color mapping utility and message intent parsing with slash commands + @mentions both implemented and fully tested</done>
</task>
<task type="auto">
<name>Task 2: ChatAgentBadge + AgentSelector components + tests</name>
<files>
ui/src/components/ChatAgentBadge.tsx,
ui/src/components/ChatAgentBadge.test.tsx,
ui/src/components/AgentSelector.tsx
</files>
<read_first>
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)
</read_first>
<action>
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: `<span className="text-[13px] text-muted-foreground truncate max-w-[120px]" aria-label={`Agent: ${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 `<Select>` component
- Trigger: `h-8 px-2 py-1`, shows current agent mini avatar (16px circle, same color mapping) + agent name (13px / truncate)
- Wrap trigger in `<Tooltip>` with content "Active agent for this conversation"
- Trigger has `aria-label="Active agent: ${currentAgent?.name ?? 'None'}"`
- Dropdown items: each agent as `<SelectItem value={agent.id}>` showing:
- 16px colored circle (same `agentRoleColorClass`) + agent name (14px / regular)
- If `agents` is empty: single disabled item "No agents configured"
- If `isLoading`: render `<Skeleton className="h-8 w-28" />`
- On value change: call `onSelect(value)`
No test file needed for AgentSelector (it's a thin UI wrapper over shadcn Select with no logic — the color mapping is tested via agent-colors, and the integration will be verified in Plan 03's checkpoint).
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui test run -- --reporter=verbose ChatAgentBadge</automated>
</verify>
<acceptance_criteria>
- test -f ui/src/components/ChatAgentBadge.tsx
- test -f ui/src/components/ChatAgentBadge.test.tsx
- test -f ui/src/components/AgentSelector.tsx
- grep -q "agentRoleColorClass" ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q 'aria-hidden="true"' ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q 'aria-label' ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q "AgentSelector" ui/src/components/AgentSelector.tsx returns 0
- grep -q "Active agent for this conversation" ui/src/components/AgentSelector.tsx returns 0
- grep -q "No agents configured" ui/src/components/AgentSelector.tsx returns 0
- pnpm --filter @paperclipai/ui test run -- ChatAgentBadge exits 0
- pnpm --filter @paperclipai/ui build exits 0 (TypeScript compiles)
</acceptance_criteria>
<done>ChatAgentBadge renders agent identity with role-based colors, AgentSelector provides dropdown to switch agents, badge tests pass, UI builds</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui test run` — all UI tests pass
- `pnpm --filter @paperclipai/ui build` — TypeScript compiles
- Agent color utility tested for all 5 roles + fallback
- parseMessageIntent tested for all 5 slash commands + @mention + plain text + edge cases
- ChatAgentBadge tested for render + fallback + accessibility
</verification>
<success_criteria>
1. agentRoleColorClass maps all 5 agent roles to chart-1 through chart-5 CSS variables
2. parseMessageIntent correctly parses all 5 slash commands and @mention syntax
3. ChatAgentBadge renders agent name + colored avatar, with fallback for unknown agents
4. AgentSelector provides a dropdown with tooltip and empty state
5. All UI tests pass and build succeeds
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,805 @@
---
phase: 22-agent-streaming
plan: 03
type: execute
wave: 2
depends_on: [22-01, 22-02]
files_modified:
- ui/src/api/chat.ts
- ui/src/hooks/useChatMessages.ts
- ui/src/hooks/useChatConversations.ts
- ui/src/components/ChatMessageList.tsx
- ui/src/components/ChatInput.tsx
- ui/src/components/ChatPanel.tsx
autonomous: true
requirements: [CHAT-01, CHAT-11, CHAT-12, PERF-02, PERF-03, INPUT-05, INPUT-06, AGENT-04, CHAT-08, CHAT-10]
must_haves:
truths:
- "User sends a message and sees tokens stream in real-time as an assistant message bubble"
- "User can click Stop to cancel an in-progress stream"
- "User can click Retry on any assistant message to regenerate the response"
- "User can edit a previous user message and trigger regeneration"
- "Agent selector in chat panel header switches the active agent for the conversation"
- "Agent badge shows above each assistant message with colored avatar and name"
- "Slash commands route messages to the correct agent for that single message"
- "@mention routes to the named agent for that single message"
- "1000+ messages render without jank using virtua VList"
- "Slash command popover appears when typing / in the input"
artifacts:
- path: "ui/src/hooks/useChatMessages.ts"
provides: "useStreamMessage hook with streaming state, partialContent, stop/send/retry/edit"
exports: ["useStreamMessage", "useEditMessage"]
- path: "ui/src/components/ChatMessageList.tsx"
provides: "Virtualized message list with VList, agent badges, edit/retry buttons, streaming indicator"
contains: "VList"
- path: "ui/src/components/ChatInput.tsx"
provides: "Stop button during streaming, slash command popover, @mention popover"
contains: "Square"
- path: "ui/src/components/ChatPanel.tsx"
provides: "AgentSelector in header, streaming state threading"
contains: "AgentSelector"
key_links:
- from: "ui/src/hooks/useChatMessages.ts"
to: "ui/src/api/chat.ts"
via: "chatApi.sendMessage + EventSource for streaming"
pattern: "EventSource|chatApi"
- from: "ui/src/components/ChatMessageList.tsx"
to: "ui/src/components/ChatAgentBadge.tsx"
via: "import { ChatAgentBadge }"
pattern: "ChatAgentBadge"
- from: "ui/src/components/ChatInput.tsx"
to: "ui/src/lib/parseMessageIntent.ts"
via: "import { parseMessageIntent, SLASH_COMMANDS }"
pattern: "parseMessageIntent"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/AgentSelector.tsx"
via: "import { AgentSelector }"
pattern: "AgentSelector"
---
<objective>
Wire all Phase 22 pieces together: streaming hook with EventSource, virtualized ChatMessageList with agent badges and action buttons, ChatInput with stop/popover/parsing, and ChatPanel integration with AgentSelector.
Purpose: This is the integration plan that connects the server SSE endpoint (Plan 01) with the UI components (Plan 02) into a working streaming chat experience.
Output: Complete streaming chat with agent selection, edit/retry, stop generation, slash commands, @mentions, and virtualized scrolling.
</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/22-agent-streaming/22-RESEARCH.md
@.planning/phases/22-agent-streaming/22-UI-SPEC.md
@.planning/phases/22-agent-streaming/22-01-SUMMARY.md
@.planning/phases/22-agent-streaming/22-02-SUMMARY.md
<interfaces>
<!-- From Plan 01 (server) -->
SSE stream endpoint:
```
GET /api/conversations/:id/stream?triggerMessageId=X
Response: text/event-stream
Events:
data: { "type": "token", "content": "word" }
data: { "type": "done", "messageId": "uuid" }
data: { "type": "error", "message": "..." }
```
Edit message endpoint:
```
PUT /api/conversations/:id/messages/:messageId
Body: { "content": "edited text" }
Response: ChatMessage with editedContent, editedAt set
```
PATCH conversation with agentId:
```
PATCH /api/conversations/:id
Body: { "agentId": "uuid" }
Response: ChatConversation with agentId updated
```
Updated ChatMessage type:
```typescript
interface ChatMessage {
id: string;
conversationId: string;
role: "user" | "assistant" | "system";
content: string;
agentId: string | null;
editedContent: string | null;
editedAt: string | null;
createdAt: string;
}
```
<!-- From Plan 02 (UI components) -->
```typescript
// ui/src/lib/agent-colors.ts
export function agentRoleColorClass(role: string): string;
// ui/src/lib/parseMessageIntent.ts
export const SLASH_COMMANDS: Record<string, string>;
export interface MessageIntent { text: string; targetRole?: string; targetName?: string; }
export function parseMessageIntent(content: string): MessageIntent;
// ui/src/components/ChatAgentBadge.tsx
export function ChatAgentBadge({ agentId, agents }: { agentId: string | null; agents: Agent[] }): JSX.Element;
// ui/src/components/AgentSelector.tsx
export function AgentSelector({ agents, currentAgentId, onSelect, isLoading }: {...}): JSX.Element;
```
<!-- Existing from Phase 21 -->
From ui/src/api/chat.ts:
```typescript
export const chatApi = {
sendMessage: (conversationId, data) => api.post<ChatMessage>(...),
updateConversation: (id, data) => api.patch<ChatConversation>(...),
listMessages: (conversationId, opts) => api.get<{ items: ChatMessage[]; hasMore: boolean }>(...),
// ...
};
```
From ui/src/hooks/useChatMessages.ts:
```typescript
export function useChatMessages(conversationId: string | null); // useInfiniteQuery
export function useSendMessage(conversationId: string | null); // useMutation
```
From ui/src/hooks/useChatConversations.ts:
```typescript
export function useConversationActions(); // returns pin/unpin/archive/remove/rename mutations
```
From ui/src/context/ChatPanelContext.tsx:
```typescript
export function useChatPanel(); // { chatOpen, setChatOpen, activeConversationId, setActiveConversationId }
```
From ui/src/lib/queryKeys.ts:
```typescript
agents: { list: (companyId: string) => ["agents", companyId] as const }
```
virtua API:
```typescript
import { VList } from "virtua";
// <VList ref={ref} style={{ flex: 1 }}>{children}</VList>
// ref.current.scrollToIndex(index, { smooth: false })
// onScroll callback provides scrollOffset, scrollSize, viewportSize
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install virtua + API client additions + useStreamMessage hook + useEditMessage hook + useUpdateConversationAgent</name>
<files>
ui/package.json,
ui/src/api/chat.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/hooks/useChatConversations.ts
</files>
<read_first>
ui/src/api/chat.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/hooks/useChatConversations.ts,
ui/src/context/ChatPanelContext.tsx,
ui/src/api/client.ts,
.planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 2: EventSource Hook)
</read_first>
<action>
0. **Install virtua:**
```bash
pnpm --filter @paperclipai/ui add virtua
```
1. **Extend `ui/src/api/chat.ts`** — Add these methods to the `chatApi` object:
```typescript
editMessage: (conversationId: string, messageId: string, data: { content: string }) =>
api.put<ChatMessage>(`/api/conversations/${conversationId}/messages/${messageId}`, data),
updateConversationAgent: (id: string, agentId: string) =>
api.patch<ChatConversation>(`/api/conversations/${id}`, { agentId }),
```
2. **Extend `ui/src/hooks/useChatMessages.ts`** — Add `useStreamMessage` hook:
```typescript
export function useStreamMessage(conversationId: string | null) {
const queryClient = useQueryClient();
const [streaming, setStreaming] = useState(false);
const [partialContent, setPartialContent] = useState("");
const esRef = useRef<EventSource | null>(null);
const stop = useCallback(() => {
esRef.current?.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
}, []);
const send = useCallback(async (content: string, agentId?: string | null) => {
if (!conversationId || streaming) return;
// Step 1: POST user message via existing API
const userMsg = await chatApi.sendMessage(conversationId, {
role: "user",
content,
agentId: agentId ?? undefined,
});
// Invalidate to show user message immediately
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
// Step 2: Open SSE stream for assistant response
setStreaming(true);
setPartialContent("");
const source = new EventSource(
`/api/conversations/${conversationId}/stream?triggerMessageId=${userMsg.id}`
);
esRef.current = source;
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as { type: string; content?: string; messageId?: string; message?: string };
if (parsed.type === "token" && parsed.content) {
setPartialContent((prev) => prev + parsed.content);
} else if (parsed.type === "done") {
source.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
// Refresh message list to show persisted assistant message
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
} else if (parsed.type === "error") {
source.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
// Toast would go here — for now log
console.error("Stream error:", parsed.message);
}
} catch {
// Ignore parse errors on SSE comments like `:ok`
}
};
source.onerror = () => {
source.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
};
}, [conversationId, streaming, queryClient]);
const retry = useCallback(async (agentId?: string | null) => {
if (!conversationId || streaming) return;
// Retry: open stream without posting a new message — server re-generates from last user message
setStreaming(true);
setPartialContent("");
const source = new EventSource(
`/api/conversations/${conversationId}/stream`
);
esRef.current = source;
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed.type === "token" && parsed.content) {
setPartialContent((prev) => prev + parsed.content);
} else if (parsed.type === "done") {
source.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
}
} catch { /* ignore */ }
};
source.onerror = () => {
source.close();
esRef.current = null;
setStreaming(false);
setPartialContent("");
};
}, [conversationId, streaming, queryClient]);
// Cleanup on unmount
useEffect(() => {
return () => {
esRef.current?.close();
};
}, []);
return { streaming, partialContent, send, stop, retry };
}
```
Add `useEditMessage` hook:
```typescript
export function useEditMessage(conversationId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ messageId, content }: { messageId: string; content: string }) =>
chatApi.editMessage(conversationId!, messageId, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
},
});
}
```
Add required imports at top: `useState, useCallback, useRef, useEffect` from react.
3. **Extend `ui/src/hooks/useChatConversations.ts`** — Add `useUpdateConversationAgent` hook:
```typescript
export function useUpdateConversationAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ conversationId, agentId }: { conversationId: string; agentId: string }) =>
chatApi.updateConversationAgent(conversationId, agentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
}
```
Add import for `chatApi` (should already be imported; if not, add `import { chatApi } from "../api/chat";`).
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui build</automated>
</verify>
<acceptance_criteria>
- grep -q "virtua" ui/package.json returns 0
- grep -q "editMessage" ui/src/api/chat.ts returns 0
- grep -q "updateConversationAgent" ui/src/api/chat.ts returns 0
- grep -q "useStreamMessage" ui/src/hooks/useChatMessages.ts returns 0
- grep -q "useEditMessage" ui/src/hooks/useChatMessages.ts returns 0
- grep -q "EventSource" ui/src/hooks/useChatMessages.ts returns 0
- grep -q "useUpdateConversationAgent" ui/src/hooks/useChatConversations.ts returns 0
- grep -q "partialContent" ui/src/hooks/useChatMessages.ts returns 0
- grep -q "streaming" ui/src/hooks/useChatMessages.ts returns 0
- pnpm --filter @paperclipai/ui build exits 0
</acceptance_criteria>
<done>virtua installed, API client extended with editMessage + updateConversationAgent, useStreamMessage hook with EventSource streaming + stop + retry, useEditMessage mutation, useUpdateConversationAgent mutation, build passes</done>
</task>
<task type="auto">
<name>Task 2: ChatMessageList virtualization + agent badges + action buttons + ChatInput streaming/popover + ChatPanel AgentSelector integration</name>
<files>
ui/src/components/ChatMessageList.tsx,
ui/src/components/ChatInput.tsx,
ui/src/components/ChatPanel.tsx
</files>
<read_first>
ui/src/components/ChatMessageList.tsx,
ui/src/components/ChatInput.tsx,
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatAgentBadge.tsx (from Plan 02),
ui/src/components/AgentSelector.tsx (from Plan 02),
ui/src/lib/parseMessageIntent.ts (from Plan 02),
ui/src/hooks/useChatMessages.ts (just updated in Task 1),
ui/src/hooks/useChatConversations.ts (just updated in Task 1),
ui/src/components/ui/command.tsx,
ui/src/components/ui/popover.tsx,
.planning/phases/22-agent-streaming/22-UI-SPEC.md (full Interaction Contract + Component Inventory)
</read_first>
<action>
**A. Rewrite `ChatMessageList.tsx`** to use virtua VList with agent badges and action buttons:
Replace the entire component. New props interface:
```typescript
interface ChatMessageListProps {
conversationId: string;
streaming: boolean;
partialContent: string;
agents: Agent[];
onRetry: () => void;
onEditMessage: (messageId: string, content: string) => void;
}
```
Implementation:
1. Import `VList` from `virtua` and create a `listRef = useRef<VListHandle>(null)` (import `VListHandle` type from virtua).
2. Replace the outer `<div role="log" ... className="overflow-y-auto flex-1">` with:
```tsx
<div className="relative flex-1 flex flex-col min-h-0">
<VList ref={listRef} style={{ flex: 1 }} className="p-4">
{allMessages.map((msg) => (
<MessageItem
key={msg.id}
message={msg}
agents={agents}
streaming={streaming}
onRetry={onRetry}
onEdit={onEditMessage}
/>
))}
{streaming && partialContent && (
<StreamingMessage content={partialContent} agents={agents} />
)}
</VList>
{!isAtBottom && (
<Button
variant="outline"
size="sm"
className="absolute bottom-20 right-4 z-10"
aria-label="Jump to bottom"
onClick={() => {
listRef.current?.scrollToIndex(allMessages.length - 1, { smooth: false });
setIsAtBottom(true);
}}
>
<ChevronDown className="h-4 w-4" />
</Button>
)}
</div>
```
3. Track `isAtBottom` state: use VList's `onScroll` callback. Virtua's VList provides `onScroll` with the scroll offset. Calculate: `isAtBottom = (event.scrollOffset + event.viewportSize >= event.scrollSize - 80)`. Initialize `isAtBottom` to `true`.
4. Auto-scroll during streaming: when `streaming` is true and `isAtBottom`, after each partialContent change, call `listRef.current?.scrollToIndex(allMessages.length, { smooth: false })` via a useEffect.
5. Keep `role="log"` and `aria-live="polite"` on an outer wrapper div (not the VList itself — VList is the scroll container).
**MessageItem** (inline component or extracted):
```tsx
function MessageItem({ message, agents, streaming, onRetry, onEdit }) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
return (
<div className={cn("group flex flex-col gap-1 mb-4", message.role === "user" ? "items-end" : "items-start")}>
{/* Agent badge for assistant messages */}
{message.role === "assistant" && (
<ChatAgentBadge agentId={message.agentId} agents={agents} />
)}
{/* Message bubble */}
<div className={cn(
"px-4 py-2 rounded-md text-sm",
message.role === "user"
? "ml-auto bg-secondary text-secondary-foreground max-w-[75%]"
: "max-w-[85%]",
)}>
{editing ? (
<div className="flex flex-col gap-2">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
aria-label="Edit message"
aria-multiline="true"
className="bg-transparent border-none resize-none text-sm focus:outline-none w-full"
style={{ minHeight: 40, maxHeight: 120 }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onEdit(message.id, editValue);
setEditing(false);
} else if (e.key === "Escape") {
setEditing(false);
setEditValue(message.content);
}
}}
/>
<Button variant="default" size="sm" onClick={() => { onEdit(message.id, editValue); setEditing(false); }}>
Regenerate
</Button>
</div>
) : message.role === "user" ? (
<span>{message.editedContent ?? message.content}</span>
) : (
<ChatMarkdownMessage content={message.editedContent ?? message.content} />
)}
</div>
{/* Timestamp */}
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{message.editedAt && " (edited)"}
</span>
{/* Action buttons — visible on hover, hidden during streaming */}
{!streaming && !editing && (
<div className="flex justify-end gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{message.role === "user" && (
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Edit message" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{message.role === "assistant" && (
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Retry response" onClick={onRetry}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
</div>
);
}
```
**StreamingMessage** (inline):
```tsx
function StreamingMessage({ content, agents }: { content: string; agents: Agent[] }) {
return (
<div className="flex flex-col gap-1 items-start mb-4" aria-live="off">
<div className="max-w-[85%] px-4 py-2 rounded-md text-sm">
<ChatMarkdownMessage content={content} />
<span className="inline-block w-2 h-2 rounded-full bg-muted animate-pulse ml-1 align-middle" aria-label="Response streaming" />
</div>
</div>
);
}
```
Imports needed: `VList` from `virtua`, `ChatAgentBadge` from `./ChatAgentBadge`, `ChatMarkdownMessage` from `./ChatMarkdownMessage`, `Button` from `@/components/ui/button`, `Pencil, RotateCcw, ChevronDown` from `lucide-react`, `Agent` from `@paperclipai/shared`, `useState, useRef, useEffect, useCallback` from `react`, `cn` from `../lib/utils`.
**B. Update `ChatInput.tsx`** — Add Stop button, slash command popover, @mention popover:
New props interface:
```typescript
interface ChatInputProps {
onSend: (content: string, intent?: MessageIntent) => void;
onStop?: () => void;
onClose?: () => void;
isSubmitting?: boolean;
streaming?: boolean;
agents?: Agent[];
className?: string;
}
```
Changes:
1. Import `parseMessageIntent, SLASH_COMMANDS, type MessageIntent` from `../lib/parseMessageIntent`.
2. Import `Square` from `lucide-react`.
3. Import `Popover, PopoverContent, PopoverTrigger` from `@/components/ui/popover`.
4. Import `Command, CommandItem, CommandList` from `@/components/ui/command`.
5. **Stop button**: When `streaming === true`, replace the Send button with:
```tsx
<Button
variant="destructive"
size="icon"
onClick={onStop}
aria-label="Stop generation"
className="h-10 w-10 shrink-0 transition-opacity duration-100"
>
<Square className="h-4 w-4" />
</Button>
```
The textarea should be disabled when `streaming === true`.
6. **handleSend** update: Parse intent before sending:
```typescript
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isSubmitting || streaming) return;
const intent = parseMessageIntent(trimmed);
onSend(intent.text || trimmed, intent);
setValue("");
if (textareaRef.current) textareaRef.current.style.height = "auto";
}, [value, isSubmitting, streaming, onSend]);
```
7. **Slash command popover**: Track `showSlashPopover` state. In `onChange`:
- If value starts with `/` and value length >= 2: filter SLASH_COMMANDS entries matching the prefix, show popover if matches > 0
- Otherwise hide popover
- Render a `<Popover open={showSlashPopover}>` positioned above the input
- Each match as `<CommandItem>` with the command label + destination agent name (from UI-SPEC table)
- On item select: replace input value with the full command + space, close popover
8. **@mention popover**: Track `showMentionPopover` state. In `onChange`:
- If value starts with `@` and length >= 2: filter agents by name prefix, show popover
- Render same `<Popover>` + `<Command>` pattern
- On item select: replace input with `@{agentName} `, close popover
The popover trigger is the textarea container itself (invisible trigger — use `<PopoverAnchor>` on the textarea wrapper div).
**C. Update `ChatPanel.tsx`** — Wire everything together:
1. Import `AgentSelector` from `./AgentSelector`.
2. Import `useStreamMessage, useEditMessage` from `../hooks/useChatMessages`.
3. Import `useUpdateConversationAgent` from `../hooks/useChatConversations`.
4. Import `useQuery` from `@tanstack/react-query`.
5. Import `agentsApi` from `../api/agents`.
6. Import `queryKeys` from `../lib/queryKeys`.
7. Import `parseMessageIntent` from `../lib/parseMessageIntent`.
8. Import `Agent` from `@paperclipai/shared`.
9. Add agent fetching:
```typescript
const { data: agents = [], isLoading: agentsLoading } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
```
10. Add streaming hook:
```typescript
const stream = useStreamMessage(activeConversationId);
const editMessage = useEditMessage(activeConversationId);
const updateAgent = useUpdateConversationAgent();
```
11. Get current conversation's agentId (use a separate query or derive from conversations list):
```typescript
const { data: activeConversation } = useQuery({
queryKey: ["chat", "conversation", activeConversationId],
queryFn: () => chatApi.getConversation(activeConversationId!),
enabled: !!activeConversationId,
});
```
12. Update `handleSend` to use streaming:
```typescript
const handleSend = useCallback(
async (content: string, intent?: MessageIntent) => {
if (!activeConversationId) {
if (!selectedCompanyId) return;
try {
const conversation = await createConversation.mutateAsync(undefined);
setActiveConversationId(conversation.id);
// Can't stream yet — conversation just created, need to wait for state update
// Queue the send for after state settles
setTimeout(() => stream.send(content, resolveAgentId(intent, agents, conversation.agentId)), 50);
} catch { /* ignore */ }
} else {
const agentId = resolveAgentIdForIntent(intent, agents, activeConversation?.agentId ?? null);
stream.send(content, agentId);
}
},
[activeConversationId, selectedCompanyId, createConversation, setActiveConversationId, stream, agents, activeConversation],
);
```
13. Add a helper function in ChatPanel or import from parseMessageIntent:
```typescript
function resolveAgentIdForIntent(
intent: MessageIntent | undefined,
agents: Agent[],
defaultAgentId: string | null,
): string | null {
if (!intent) return defaultAgentId;
if (intent.targetRole) {
const match = agents.find(a => a.role === intent.targetRole);
return match?.id ?? defaultAgentId;
}
if (intent.targetName) {
const match = agents.find(a => a.name.toLowerCase() === intent.targetName);
return match?.id ?? defaultAgentId;
}
return defaultAgentId;
}
```
14. Add `handleAgentSelect`:
```typescript
const handleAgentSelect = useCallback((agentId: string) => {
if (!activeConversationId) return;
updateAgent.mutate({ conversationId: activeConversationId, agentId });
}, [activeConversationId, updateAgent]);
```
15. Add `handleRetry` and `handleEditMessage`:
```typescript
const handleRetry = useCallback(() => {
stream.retry(activeConversation?.agentId ?? null);
}, [stream, activeConversation]);
const handleEditMessage = useCallback((messageId: string, content: string) => {
editMessage.mutate({ messageId, content });
// After edit, trigger re-generation
stream.retry(activeConversation?.agentId ?? null);
}, [editMessage, stream, activeConversation]);
```
16. Add `AgentSelector` to the panel header. Modify the inner layout — add a header bar above the message area:
```tsx
{/* Message area */}
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
{/* Header with agent selector */}
{activeConversationId && (
<div className="flex items-center border-b border-border px-3 h-12 shrink-0">
<AgentSelector
agents={agents}
currentAgentId={activeConversation?.agentId ?? null}
onSelect={handleAgentSelect}
isLoading={agentsLoading}
/>
</div>
)}
{activeConversationId ? (
<ChatMessageList
conversationId={activeConversationId}
streaming={stream.streaming}
partialContent={stream.partialContent}
agents={agents}
onRetry={handleRetry}
onEditMessage={handleEditMessage}
/>
) : (
<div className="flex flex-1 items-center justify-center p-4 text-center">
<p className="text-sm text-muted-foreground">Select a conversation or start a new one.</p>
</div>
)}
<ChatInput
onSend={handleSend}
onStop={stream.stop}
onClose={handleClose}
isSubmitting={sendMessage.isPending || createConversation.isPending}
streaming={stream.streaming}
agents={agents}
/>
</div>
```
17. Remove the old `sendMessage` useSendMessage hook usage since streaming now handles sending. Keep the import for `useSendMessage` only if still needed for non-streaming fallback, otherwise remove.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run</automated>
</verify>
<acceptance_criteria>
- grep -q "VList" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "virtua" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "ChatAgentBadge" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "isAtBottom" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "Jump to bottom" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "Pencil" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "RotateCcw" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "animate-pulse" ui/src/components/ChatMessageList.tsx returns 0
- grep -q "Square" ui/src/components/ChatInput.tsx returns 0
- grep -q "Stop generation" ui/src/components/ChatInput.tsx returns 0
- grep -q "parseMessageIntent" ui/src/components/ChatInput.tsx returns 0
- grep -q "SLASH_COMMANDS" ui/src/components/ChatInput.tsx returns 0
- grep -q "AgentSelector" ui/src/components/ChatPanel.tsx returns 0
- grep -q "useStreamMessage" ui/src/components/ChatPanel.tsx returns 0
- grep -q "useEditMessage" ui/src/components/ChatPanel.tsx returns 0
- grep -q "useUpdateConversationAgent" ui/src/components/ChatPanel.tsx returns 0
- grep -q "resolveAgentIdForIntent" ui/src/components/ChatPanel.tsx returns 0
- pnpm --filter @paperclipai/ui build exits 0
- pnpm --filter @paperclipai/ui test run exits 0
</acceptance_criteria>
<done>ChatMessageList uses VList with agent badges, edit/retry buttons, streaming indicator, and jump-to-bottom. ChatInput has Stop button, slash command popover, and @mention popover. ChatPanel integrates AgentSelector, streaming, edit, retry, and agent resolution. Build and all tests pass.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` — TypeScript compiles
- `pnpm --filter @paperclipai/ui test run` — all UI tests pass
- `pnpm test run` — full suite green
- ChatMessageList uses VList from virtua
- ChatInput shows Stop button during streaming
- ChatPanel has AgentSelector in header
- Slash commands and @mentions are parsed and routed
</verification>
<success_criteria>
1. Streaming tokens appear in a live assistant message bubble via EventSource
2. Stop button (Square icon, destructive variant) replaces Send during streaming
3. Retry button (RotateCcw) appears on hover over assistant messages
4. Edit button (Pencil) appears on hover over user messages with inline textarea
5. AgentSelector in ChatPanel header shows all agents and persists selection via PATCH
6. VList virtualizes the message list for smooth scrolling with 1000+ messages
7. Slash commands populate a popover and route to correct agent role
8. @mention popover shows filtered agents and routes to named agent
9. Jump to bottom button appears when user scrolls up
10. All tests pass and build succeeds
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`
</output>

View file

@ -0,0 +1,128 @@
---
phase: 22-agent-streaming
plan: 04
type: execute
wave: 3
depends_on: [22-03]
files_modified: []
autonomous: false
requirements: [CHAT-01, CHAT-08, CHAT-10, CHAT-11, CHAT-12, INPUT-05, INPUT-06, AGENT-04, THEME-03, PERF-02, PERF-03]
must_haves:
truths:
- "Full streaming chat flow works end-to-end"
- "All three themes render agent colors correctly"
- "All six success criteria from ROADMAP are met"
artifacts: []
key_links: []
---
<objective>
Full test suite verification and visual/functional checkpoint for the complete Phase 22 agent streaming feature.
Purpose: Ensures all automated tests pass and gives the user a chance to verify the streaming experience, agent selector, theme colors, and interaction flows visually.
Output: Verified, working Phase 22.
</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/22-agent-streaming/22-01-SUMMARY.md
@.planning/phases/22-agent-streaming/22-02-SUMMARY.md
@.planning/phases/22-agent-streaming/22-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Full test suite verification and build check</name>
<files></files>
<read_first>
server/src/__tests__/chat-stream-routes.test.ts,
server/src/__tests__/chat-routes.test.ts,
ui/src/lib/parseMessageIntent.test.ts,
ui/src/components/ChatAgentBadge.test.tsx
</read_first>
<action>
Run the full test suite and verify all tests pass:
```bash
pnpm test run
```
If any tests fail:
1. Read the failing test file and the source file it tests
2. Fix the issue (prefer fixing source code over weakening tests)
3. Re-run until all tests pass
Then verify the build:
```bash
pnpm --filter @paperclipai/ui build
pnpm --filter @paperclipai/server build
```
Report the total test count and pass rate.
</action>
<verify>
<automated>pnpm test run && pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/server build</automated>
</verify>
<acceptance_criteria>
- pnpm test run exits 0
- pnpm --filter @paperclipai/ui build exits 0
- pnpm --filter @paperclipai/server build exits 0
</acceptance_criteria>
<done>All tests pass and both UI and server build cleanly</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual and functional verification of streaming chat</name>
<files></files>
<action>
Present the verification checklist to the user. The user will manually test the streaming chat experience across all features built in Phase 22:
1. Start the dev server: `pnpm dev`
2. Open the chat panel (MessageSquare icon in sidebar)
3. Create a new conversation and send a message — verify tokens stream in word-by-word
4. While streaming: verify the Stop button (red square) appears; click it to cancel
5. Hover over an assistant message — verify Retry button (rotate icon) appears; click it
6. Hover over a user message — verify Edit button (pencil icon) appears; click to enter edit mode, modify text, click Regenerate
7. Open the Agent Selector dropdown in the header — verify agents appear with colored avatars
8. Select a different agent — verify it persists (reload page, re-open conversation)
9. Type `/ask-pm ` — verify slash command popover appears with matching commands
10. Type `@` followed by an agent name — verify mention popover appears
11. Switch between all three themes (Catppuccin Mocha, Tokyo Night, Catppuccin Latte) — verify agent badge colors are distinguishable
12. (Optional) If you have a conversation with many messages, scroll rapidly to check smoothness
</action>
<verify>User types "approved" or describes issues to fix</verify>
<acceptance_criteria>
- User confirms streaming tokens appear as they are generated
- User confirms Stop button cancels in-progress stream
- User confirms agent badge shows on assistant messages with colored avatar
- User confirms agent selector changes the conversation's agent
- User confirms slash command and @mention popovers appear
- User confirms agent colors are distinguishable across all three themes
</acceptance_criteria>
<done>User has approved the complete Phase 22 streaming chat experience</done>
</task>
</tasks>
<verification>
- Full test suite green
- Build succeeds for both UI and server
- User has visually verified the streaming experience
</verification>
<success_criteria>
1. All automated tests pass
2. Build succeeds
3. User approves the streaming experience
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`
</output>