[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:
parent
2f60c16d8d
commit
1b64970e90
5 changed files with 1650 additions and 2 deletions
|
|
@ -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 | - |
|
||||
|
|
|
|||
382
.planning/phases/22-agent-streaming/22-01-PLAN.md
Normal file
382
.planning/phases/22-agent-streaming/22-01-PLAN.md
Normal 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>
|
||||
328
.planning/phases/22-agent-streaming/22-02-PLAN.md
Normal file
328
.planning/phases/22-agent-streaming/22-02-PLAN.md
Normal 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>
|
||||
805
.planning/phases/22-agent-streaming/22-03-PLAN.md
Normal file
805
.planning/phases/22-agent-streaming/22-03-PLAN.md
Normal 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>
|
||||
128
.planning/phases/22-agent-streaming/22-04-PLAN.md
Normal file
128
.planning/phases/22-agent-streaming/22-04-PLAN.md
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue