docs(22-agent-streaming): create phase plan — 6 plans in 4 waves

This commit is contained in:
Nexus Dev 2026-04-01 17:50:07 +00:00
parent 01bde5398c
commit 9d1a91b4bc
7 changed files with 3117 additions and 5 deletions

View file

@ -55,7 +55,15 @@ 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:** 6 plans
Plans:
- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs
- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook
- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector
- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls
- [ ] 22-04-PLAN.md — Slash commands and @mention popovers
- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration
**UI hint**: yes
### Phase 23: Brainstormer Flow
@ -68,7 +76,15 @@ Plans:
3. When the user clicks "Send to PM," a handoff indicator appears in the chat showing "Brainstormer → PM" with the spec content
4. The PM agent creates one or more Nexus issues from the spec; the user can see task IDs referenced in the PM's reply
5. When an Engineer or Generalist completes a task, a status update message appears in the relevant chat conversation
**Plans**: TBD
**Plans:** 6 plans
Plans:
- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs
- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook
- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector
- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls
- [ ] 22-04-PLAN.md — Slash commands and @mention popovers
- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration
**UI hint**: yes
### Phase 24: Search, History & Branching
@ -80,7 +96,15 @@ Plans:
2. User can bookmark any message and later filter or navigate to bookmarked messages
3. Editing a message that already has a response creates a new branch; both the original and the new branch are preserved and the user can switch between them
4. User can export any conversation as a Markdown file or as a JSON file containing all messages and metadata
**Plans**: TBD
**Plans:** 6 plans
Plans:
- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs
- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook
- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector
- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls
- [ ] 22-04-PLAN.md — Slash commands and @mention popovers
- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration
**UI hint**: yes
### Phase 25: File System
@ -95,7 +119,15 @@ Plans:
5. When an agent generates a placeholder asset, `PLACEHOLDERS.md` is updated in the project directory; when the placeholder is replaced, the DB records the replacement chain and the manifest reflects the change
6. A file uploaded in a conversation linked to a project lives in `files/projects/<slug>/`; a file from an unlinked conversation lives in `files/chat/<conversation-id>/`; the user can promote a chat file to project scope
7. Voice input is available when local AI is enabled: user can hold the record button, speak, see a transcription preview, and confirm to send
**Plans**: TBD
**Plans:** 6 plans
Plans:
- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs
- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook
- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector
- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls
- [ ] 22-04-PLAN.md — Slash commands and @mention popovers
- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration
**UI hint**: yes
### Phase 26: PWA & Performance
@ -109,7 +141,15 @@ Plans:
4. On a phone, the input bar is sticky at the bottom of the screen, touch targets are large enough to tap without errors, and the layout resizes correctly when the software keyboard appears
5. Pulling down on the conversation list on mobile triggers a refresh; push notifications arrive for agent mentions, task completions, and handoff requests where the platform supports them
6. The initial page load on broadband completes in under 2 seconds and on a 3G connection in under 5 seconds; PWA cached load completes in under 1 second
**Plans**: TBD
**Plans:** 6 plans
Plans:
- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs
- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook
- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector
- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls
- [ ] 22-04-PLAN.md — Slash commands and @mention popovers
- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration
**UI hint**: yes
---

View file

@ -0,0 +1,392 @@
---
phase: 22-agent-streaming
plan: "00"
type: execute
wave: 0
depends_on: []
files_modified:
- packages/db/src/schema/chat_messages.ts
- packages/db/src/migrations/0048_add_chat_messages_updated_at.sql
- packages/shared/src/types/chat.ts
- ui/src/lib/agent-role-colors.ts
- ui/src/lib/agent-role-colors.test.ts
- ui/src/hooks/useStreamingChat.test.ts
- ui/src/components/ChatAgentSelector.test.tsx
- ui/src/components/ChatMessage.test.tsx
- ui/src/components/ChatSlashCommandPopover.test.tsx
- ui/src/components/ChatMentionPopover.test.tsx
- ui/src/components/ChatMessageIdentityBar.test.tsx
- ui/src/components/ChatMessageList.test.tsx
- ui/src/index.css
- ui/package.json
autonomous: true
requirements:
- THEME-03
must_haves:
truths:
- "agent-role-colors.ts exports a color class for every AgentRole value from AGENT_ROLES"
- "chat_messages table has an updated_at column"
- "ChatMessage shared type includes updatedAt field"
- "@tanstack/react-virtual is installed in ui workspace"
- "Cursor blink animation is declared in index.css"
- "All Wave 0 test stubs exist and run without error"
artifacts:
- path: "ui/src/lib/agent-role-colors.ts"
provides: "AgentRole to Tailwind class map"
exports: ["agentRoleColors", "agentRoleColorDefault"]
- path: "packages/db/src/schema/chat_messages.ts"
provides: "updatedAt column on chat_messages"
contains: "updatedAt"
- path: "packages/shared/src/types/chat.ts"
provides: "updatedAt on ChatMessage type"
contains: "updatedAt"
key_links:
- from: "ui/src/lib/agent-role-colors.ts"
to: "@paperclipai/shared constants"
via: "import AgentRole"
pattern: "import.*AgentRole"
---
<objective>
Wave 0 foundation: DB migration adding `updated_at` to `chat_messages`, shared type update, install `@tanstack/react-virtual`, create `agent-role-colors.ts` utility (THEME-03), cursor-blink CSS animation, and all test stubs for Phase 22.
Purpose: Provide the schema, types, dependencies, and test scaffolds that all subsequent plans need.
Output: Migration file, updated schema, shared types, agent-role-colors utility, CSS animation, test stubs, installed virtualizer package.
</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-VALIDATION.md
<interfaces>
From packages/shared/src/constants.ts:
```typescript
export const AGENT_ROLES = [
"pm", "engineer", "ceo", "general", "designer",
"qa", "researcher", "devops", "cto", "cmo", "cfo",
] as const;
export type AgentRole = (typeof AGENT_ROLES)[number];
```
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(),
}, (table) => ({
conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt),
}));
```
From packages/shared/src/types/chat.ts:
```typescript
export interface ChatMessage {
id: string;
conversationId: string;
role: "user" | "assistant" | "system";
content: string;
agentId: string | null;
createdAt: string;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: DB migration, shared types, install virtualizer, agent-role-colors, CSS animation</name>
<read_first>
- packages/db/src/schema/chat_messages.ts
- packages/shared/src/types/chat.ts
- packages/shared/src/constants.ts
- ui/src/index.css
- ui/src/lib/status-colors.ts
- ui/package.json
</read_first>
<files>
packages/db/src/schema/chat_messages.ts,
packages/db/src/migrations/0048_add_chat_messages_updated_at.sql,
packages/shared/src/types/chat.ts,
ui/src/lib/agent-role-colors.ts,
ui/src/lib/agent-role-colors.test.ts,
ui/src/index.css,
ui/package.json
</files>
<action>
1. Add `updatedAt` column to `chat_messages` Drizzle schema:
In `packages/db/src/schema/chat_messages.ts`, add after `createdAt`:
```
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
```
Note: NOT `.notNull()` — existing rows will have null until updated.
2. Create migration `packages/db/src/migrations/0048_add_chat_messages_updated_at.sql`:
```sql
ALTER TABLE "chat_messages" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();
```
3. Update `packages/shared/src/types/chat.ts` — add `updatedAt` to `ChatMessage` interface:
```
updatedAt: string | null;
```
4. Install `@tanstack/react-virtual`:
```bash
pnpm add @tanstack/react-virtual --filter @paperclipai/ui
```
5. Create `ui/src/lib/agent-role-colors.ts`:
```typescript
import type { AgentRole } from "@paperclipai/shared";
export const agentRoleColors: Record<AgentRole, string> = {
pm: "text-blue-600 dark:text-blue-400",
engineer: "text-violet-600 dark:text-violet-400",
ceo: "text-yellow-600 dark:text-yellow-400",
general: "text-yellow-600 dark:text-yellow-400",
designer: "text-pink-600 dark:text-pink-400",
qa: "text-orange-600 dark:text-orange-400",
researcher: "text-teal-600 dark:text-teal-400",
devops: "text-green-600 dark:text-green-400",
cto: "text-green-600 dark:text-green-400",
cmo: "text-neutral-600 dark:text-neutral-400",
cfo: "text-neutral-600 dark:text-neutral-400",
};
export const agentRoleColorDefault = "text-muted-foreground";
```
6. Create `ui/src/lib/agent-role-colors.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { AGENT_ROLES } from "@paperclipai/shared";
import { agentRoleColors, agentRoleColorDefault } from "./agent-role-colors";
describe("agentRoleColors", () => {
it("has an entry for every AGENT_ROLES value", () => {
for (const role of AGENT_ROLES) {
expect(agentRoleColors[role]).toBeDefined();
expect(agentRoleColors[role]).toContain("text-");
}
});
it("each entry has both light and dark variant", () => {
for (const role of AGENT_ROLES) {
expect(agentRoleColors[role]).toContain("dark:");
}
});
it("exports a default fallback color", () => {
expect(agentRoleColorDefault).toBe("text-muted-foreground");
});
});
```
7. Add cursor-blink animation to `ui/src/index.css` (append before the closing of the file):
```css
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-cursor-blink {
animation: cursor-blink 800ms step-start infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-cursor-blink {
animation: none;
opacity: 1;
}
}
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui vitest run src/lib/agent-role-colors.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- grep -q "updatedAt" packages/db/src/schema/chat_messages.ts
- grep -q "updated_at" packages/db/src/migrations/0048_add_chat_messages_updated_at.sql
- grep -q "updatedAt" packages/shared/src/types/chat.ts
- grep -q "agentRoleColors" ui/src/lib/agent-role-colors.ts
- grep -q "agentRoleColorDefault" ui/src/lib/agent-role-colors.ts
- grep -q "cursor-blink" ui/src/index.css
- grep -q "@tanstack/react-virtual" ui/package.json
- grep -q "prefers-reduced-motion" ui/src/index.css
</acceptance_criteria>
<done>
- chat_messages schema has updatedAt column
- Migration 0048 exists with ALTER TABLE
- ChatMessage shared type has updatedAt: string | null
- @tanstack/react-virtual installed in ui workspace
- agent-role-colors.ts exports map for all 11 AgentRole values with light+dark variants
- agent-role-colors.test.ts passes (3 tests)
- cursor-blink CSS animation in index.css with reduced-motion guard
</done>
</task>
<task type="auto">
<name>Task 2: Wave 0 test stubs for all Phase 22 components/hooks</name>
<read_first>
- ui/src/hooks/useChatMessages.ts
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/components/ChatInput.tsx
</read_first>
<files>
ui/src/hooks/useStreamingChat.test.ts,
ui/src/components/ChatAgentSelector.test.tsx,
ui/src/components/ChatMessage.test.tsx,
ui/src/components/ChatSlashCommandPopover.test.tsx,
ui/src/components/ChatMentionPopover.test.tsx,
ui/src/components/ChatMessageIdentityBar.test.tsx,
ui/src/components/ChatMessageList.test.tsx
</files>
<action>
Create test stub files using `it.todo()` pattern (established in Phase 21 Wave 0). Minimal imports, no service mocks.
1. `ui/src/hooks/useStreamingChat.test.ts`:
```typescript
import { describe, it } from "vitest";
describe("useStreamingChat", () => {
it.todo("accumulates tokens from SSE data events into streamingContent");
it.todo("sets isStreaming=true when stream starts, false when done");
it.todo("clears streamingContent and invalidates query cache on done event");
it.todo("stop() closes the EventSource and sets isStreaming=false");
it.todo("handles SSE error event by closing connection");
});
```
2. `ui/src/components/ChatAgentSelector.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatAgentSelector", () => {
it.todo("renders active agent icon and name when agentId is set");
it.todo("renders 'Select agent' placeholder when no agent selected");
it.todo("lists all workspace agents in dropdown");
it.todo("calls onAgentChange with new agentId on selection");
it.todo("shows 'No agents configured' when agent list is empty");
});
```
3. `ui/src/components/ChatMessage.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatMessage", () => {
it.todo("renders user message as right-aligned bubble with plain text");
it.todo("renders assistant message with ChatMarkdownMessage");
it.todo("renders ChatMessageIdentityBar for assistant messages when agentName is provided");
it.todo("shows edit pencil on hover for user messages");
it.todo("shows retry button on hover for assistant messages");
it.todo("hides retry button when isStreaming is true");
it.todo("switches to inline edit textarea on pencil click");
it.todo("renders ChatStreamingCursor when isStreaming is true");
});
```
4. `ui/src/components/ChatSlashCommandPopover.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatSlashCommandPopover", () => {
it.todo("renders 5 slash command items when open");
it.todo("filters commands by typed query");
it.todo("calls onSelect with command string on item click");
it.todo("closes on Escape key");
it.todo("shows /search as greyed out with 'Coming soon' suffix");
});
```
5. `ui/src/components/ChatMentionPopover.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatMentionPopover", () => {
it.todo("renders agent list filtered by query string");
it.todo("shows agent icon, name, and role for each item");
it.todo("calls onSelect with @agentName on item click");
it.todo("shows 'No agents found' when filter matches nothing");
});
```
6. `ui/src/components/ChatMessageIdentityBar.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatMessageIdentityBar", () => {
it.todo("renders agent icon at 16x16px");
it.todo("renders agent name in semibold text");
it.todo("renders timestamp in muted text");
it.todo("applies role-specific color from agentRoleColors");
});
```
7. `ui/src/components/ChatMessageList.test.tsx`:
```typescript
import { describe, it } from "vitest";
describe("ChatMessageList", () => {
it.todo("renders messages using virtualizer");
it.todo("auto-scrolls to bottom when new messages arrive");
it.todo("shows loading skeleton when isLoading");
it.todo("shows empty state when no messages");
it.todo("appends streaming message as synthetic entry");
});
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui vitest run --reporter=verbose 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- grep -q "it.todo" ui/src/hooks/useStreamingChat.test.ts
- grep -q "it.todo" ui/src/components/ChatAgentSelector.test.tsx
- grep -q "it.todo" ui/src/components/ChatMessage.test.tsx
- grep -q "it.todo" ui/src/components/ChatSlashCommandPopover.test.tsx
- grep -q "it.todo" ui/src/components/ChatMentionPopover.test.tsx
- grep -q "it.todo" ui/src/components/ChatMessageIdentityBar.test.tsx
- grep -q "it.todo" ui/src/components/ChatMessageList.test.tsx
</acceptance_criteria>
<done>
- 7 test stub files exist with it.todo() entries
- All test stubs run without error (todos are not failures)
- Full UI test suite passes
</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes (all existing + new tests green, todos listed)
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes (type check)
- `grep -q "@tanstack/react-virtual" ui/package.json` confirms install
</verification>
<success_criteria>
- DB migration 0048 adds updated_at to chat_messages
- ChatMessage type includes updatedAt
- @tanstack/react-virtual installed
- agent-role-colors.ts covers all 11 roles with themed classes
- Cursor-blink CSS animation declared with reduced-motion guard
- All 7 Wave 0 test stub files exist and run
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-00-SUMMARY.md`
</output>

View file

@ -0,0 +1,476 @@
---
phase: 22-agent-streaming
plan: "01"
type: execute
wave: 1
depends_on: ["22-00"]
files_modified:
- server/src/services/chat.ts
- server/src/routes/chat.ts
- ui/src/hooks/useStreamingChat.ts
- ui/src/hooks/useStreamingChat.test.ts
- ui/src/api/chat.ts
autonomous: true
requirements:
- CHAT-01
- CHAT-12
- PERF-02
must_haves:
truths:
- "Server SSE endpoint streams token events as text/event-stream"
- "Client hook accumulates tokens into streamingContent string"
- "User can stop a stream mid-generation and partial content is preserved"
- "First SSE headers are flushed before any LLM generation begins"
artifacts:
- path: "server/src/routes/chat.ts"
provides: "POST /conversations/:id/stream SSE endpoint"
contains: "text/event-stream"
- path: "server/src/services/chat.ts"
provides: "editMessage and truncateMessagesAfter methods"
contains: "editMessage"
- path: "ui/src/hooks/useStreamingChat.ts"
provides: "SSE lifecycle hook"
exports: ["useStreamingChat"]
- path: "ui/src/api/chat.ts"
provides: "postMessageAndStream method"
contains: "postMessageAndStream"
key_links:
- from: "ui/src/hooks/useStreamingChat.ts"
to: "server POST /conversations/:id/stream"
via: "fetch with ReadableStream"
pattern: "fetch.*stream"
- from: "server/src/routes/chat.ts"
to: "server/src/services/chat.ts"
via: "svc.addMessage for final commit"
pattern: "svc\\.addMessage"
---
<objective>
SSE streaming endpoint on the server and `useStreamingChat` hook on the client. The server uses a stub echo stream (repeats user message as fake tokens) since real LLM integration is Phase 23. The client uses `fetch` with `ReadableStream` (not native `EventSource`) because the stream endpoint is POST-based.
Purpose: Enable real-time token-by-token message delivery (CHAT-01), stop generation (CHAT-12), and sub-100ms first-token latency (PERF-02).
Output: Server SSE route, chat service edit/truncate methods, client streaming hook, updated chat API client.
</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-00-SUMMARY.md
<interfaces>
From server/src/routes/chat.ts (existing):
```typescript
export function chatRoutes(db: Db): Router {
const router = Router();
const svc = chatService(db);
// ... existing routes: POST /conversations, GET /conversations/:id, etc.
}
```
From server/src/services/chat.ts (existing):
```typescript
export function chatService(db: Db) {
return {
createConversation(...), listConversations(...), getConversation(...),
updateConversation(...), softDeleteConversation(...),
listMessages(...), addMessage(...)
};
}
```
From server/src/routes/plugins.ts (SSE pattern):
```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");
```
From ui/src/api/chat.ts (existing):
```typescript
export const chatApi = {
listConversations(...), createConversation(...), getConversation(...),
updateConversation(...), deleteConversation(...), listMessages(...), postMessage(...)
};
```
From ui/src/hooks/useChatMessages.ts:
```typescript
export function useChatMessages(conversationId: string | null) {
// queryKey: ["chat", "messages", conversationId]
// sendMutation invalidates: ["chat", "messages", conversationId] and ["chat", "conversations"]
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Server SSE streaming endpoint + edit/truncate service methods</name>
<read_first>
- server/src/routes/chat.ts
- server/src/services/chat.ts
- server/src/routes/plugins.ts (lines 1140-1185 for SSE pattern)
- packages/db/src/schema/chat_messages.ts
</read_first>
<files>
server/src/services/chat.ts,
server/src/routes/chat.ts
</files>
<action>
**1. Add three new methods to `chatService` in `server/src/services/chat.ts`:**
a) `editMessage(messageId: string, content: string)` — Updates a message's content and updatedAt:
```typescript
async editMessage(messageId: string, content: string) {
const [row] = await db
.update(chatMessages)
.set({ content, updatedAt: new Date() })
.where(eq(chatMessages.id, messageId))
.returning();
return row;
},
```
b) `truncateMessagesAfter(conversationId: string, messageId: string)` — Deletes all messages in the conversation created after the given message:
```typescript
async truncateMessagesAfter(conversationId: string, messageId: string) {
// Get the target message's createdAt
const [target] = await db
.select({ createdAt: chatMessages.createdAt })
.from(chatMessages)
.where(eq(chatMessages.id, messageId));
if (!target) return;
await db
.delete(chatMessages)
.where(
and(
eq(chatMessages.conversationId, conversationId),
gt(chatMessages.createdAt, target.createdAt),
),
);
},
```
Import `gt` from `drizzle-orm` alongside existing imports.
c) `streamEcho(content: string, signal: AbortSignal)` — Async generator that yields fake tokens (stub for Phase 23 real LLM):
```typescript
async *streamEcho(content: string, signal: AbortSignal) {
const words = content.split(/\s+/);
for (const word of words) {
if (signal.aborted) break;
await new Promise((r) => setTimeout(r, 50));
yield word + " ";
}
},
```
**2. Add three new routes to `chatRoutes` in `server/src/routes/chat.ts`:**
a) `POST /conversations/:id/stream` — SSE streaming endpoint:
```typescript
router.post("/conversations/:id/stream", async (req, res) => {
assertBoard(req);
const { content, agentId } = req.body;
if (!content || typeof content !== "string") {
res.status(400).json({ error: "content is required" });
return;
}
// Set SSE headers and flush BEFORE any generation (PERF-02)
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
res.write(":ok\n\n");
const abort = new AbortController();
req.on("close", () => abort.abort());
try {
let fullContent = "";
for await (const token of svc.streamEcho(content, abort.signal)) {
if (!res.writable) break;
fullContent += token;
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
if (res.writable && !abort.signal.aborted) {
const message = await svc.addMessage(req.params.id!, {
role: "assistant",
content: fullContent.trim(),
agentId: agentId || undefined,
});
res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`);
}
} catch (err) {
if (res.writable && !abort.signal.aborted) {
res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`);
}
} finally {
res.end();
}
});
```
CRITICAL: `res.flushHeaders()` MUST be called before the for-await loop. Check `res.writable` before every `res.write()` (same guard pattern as `plugins.ts`).
b) `PATCH /conversations/:id/messages/:msgId` — Edit message content:
```typescript
router.patch("/conversations/:id/messages/:msgId", async (req, res) => {
assertBoard(req);
const { content } = req.body;
if (!content || typeof content !== "string") {
res.status(400).json({ error: "content is required" });
return;
}
const message = await svc.editMessage(req.params.msgId!, content);
if (!message) {
res.status(404).json({ error: "Message not found" });
return;
}
res.json(message);
});
```
c) `DELETE /conversations/:id/messages/after/:msgId` — Truncate messages after a given message:
```typescript
router.delete("/conversations/:id/messages/after/:msgId", async (req, res) => {
assertBoard(req);
await svc.truncateMessagesAfter(req.params.id!, req.params.msgId!);
res.status(204).end();
});
```
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "text/event-stream" server/src/routes/chat.ts
- grep -q "flushHeaders" server/src/routes/chat.ts
- grep -q "editMessage" server/src/services/chat.ts
- grep -q "truncateMessagesAfter" server/src/services/chat.ts
- grep -q "streamEcho" server/src/services/chat.ts
- grep -q "res.writable" server/src/routes/chat.ts
- grep -q "/conversations/:id/stream" server/src/routes/chat.ts
- grep -q "/conversations/:id/messages/:msgId" server/src/routes/chat.ts
- grep -q "/conversations/:id/messages/after/:msgId" server/src/routes/chat.ts
</acceptance_criteria>
<done>
- POST /conversations/:id/stream SSE endpoint exists with proper headers flushed before generation
- PATCH /conversations/:id/messages/:msgId edits message content
- DELETE /conversations/:id/messages/after/:msgId truncates subsequent messages
- chatService has editMessage, truncateMessagesAfter, and streamEcho methods
- All routes check res.writable before writing (prevents write-after-end)
- Server TypeScript compiles without errors in chat files
</done>
</task>
<task type="auto">
<name>Task 2: useStreamingChat hook and chat API stream method</name>
<read_first>
- ui/src/hooks/useChatMessages.ts
- ui/src/api/chat.ts
- ui/src/plugins/bridge.ts
- ui/src/hooks/useStreamingChat.test.ts
</read_first>
<files>
ui/src/hooks/useStreamingChat.ts,
ui/src/api/chat.ts
</files>
<action>
**1. Add stream-related methods to `chatApi` in `ui/src/api/chat.ts`:**
```typescript
async postMessageAndStream(
conversationId: string,
data: { content: string; agentId?: string },
callbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
},
signal?: AbortSignal,
) {
const res = await fetch(`/api/conversations/${conversationId}/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
credentials: "include",
signal,
});
if (!res.ok || !res.body) {
callbacks.onError("Failed to start stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const json = line.slice(6);
try {
const parsed = JSON.parse(json) as { token?: string; done?: boolean; messageId?: string; content?: string; error?: string };
if (parsed.token) callbacks.onToken(parsed.token);
if (parsed.done && parsed.messageId) callbacks.onDone(parsed.messageId, parsed.content ?? "");
if (parsed.error) callbacks.onError(parsed.error);
} catch { /* ignore malformed lines */ }
}
}
} catch (err) {
if (signal?.aborted) return; // Expected on stop
callbacks.onError("Stream connection lost");
}
},
async savePartialMessage(conversationId: string, data: { role: "assistant"; content: string; agentId?: string }) {
return chatApi.postMessage(conversationId, data);
},
```
Use `fetch` with `ReadableStream` instead of `EventSource` because the endpoint is POST-based. `EventSource` only supports GET (Open Question 2 from RESEARCH.md).
**2. Create `ui/src/hooks/useStreamingChat.ts`:**
```typescript
import { useRef, useState, useTransition, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
export function useStreamingChat(conversationId: string | null) {
const [streamingContent, setStreamingContent] = useState<string>("");
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const queryClient = useQueryClient();
const [, startTransition] = useTransition();
const startStream = useCallback(
(userMessage: string, agentId?: string) => {
if (!conversationId) return;
setIsStreaming(true);
setStreamingContent("");
const abort = new AbortController();
abortRef.current = abort;
chatApi.postMessageAndStream(
conversationId,
{ content: userMessage, agentId },
{
onToken: (token: string) => {
startTransition(() => {
setStreamingContent((prev) => prev + token);
});
},
onDone: (messageId: string, content: string) => {
setIsStreaming(false);
setStreamingContent("");
abortRef.current = null;
// Optimistically insert the completed message into cache to avoid flash (Pitfall 2)
queryClient.setQueryData(
["chat", "messages", conversationId],
(old: unknown) => old, // Keep existing data — invalidation will refetch
);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
onError: (error: string) => {
setIsStreaming(false);
abortRef.current = null;
console.error("[useStreamingChat] Stream error:", error);
},
},
abort.signal,
);
},
[conversationId, queryClient, startTransition],
);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
const partial = streamingContent;
setIsStreaming(false);
setStreamingContent("");
// Persist partial content with [stopped] suffix (Open Question 3)
if (conversationId && partial.trim()) {
chatApi.savePartialMessage(conversationId, {
role: "assistant",
content: partial.trim() + " [stopped]",
}).then(() => {
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
});
}
}, [conversationId, streamingContent, queryClient]);
return { streamingContent, isStreaming, startStream, stop };
}
```
Key design decisions:
- `startTransition` wraps `setStreamingContent` so token appends don't block user input (PERF-02)
- `AbortController` for stop (CHAT-12) — server detects `req.on("close")`
- On stop, partial content saved with " [stopped]" suffix to DB
- On done, cache invalidated (not optimistically set) to let React Query refetch the canonical data
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "useStreamingChat" ui/src/hooks/useStreamingChat.ts
- grep -q "postMessageAndStream" ui/src/api/chat.ts
- grep -q "startTransition" ui/src/hooks/useStreamingChat.ts
- grep -q "AbortController" ui/src/hooks/useStreamingChat.ts
- grep -q "\\[stopped\\]" ui/src/hooks/useStreamingChat.ts
- grep -q "savePartialMessage" ui/src/api/chat.ts
- grep -q "ReadableStream\\|getReader" ui/src/api/chat.ts
</acceptance_criteria>
<done>
- useStreamingChat hook exists with startStream, stop, streamingContent, isStreaming
- chatApi.postMessageAndStream uses fetch ReadableStream for POST SSE
- chatApi.savePartialMessage persists partial content on stop
- startTransition used for token accumulation (PERF-02)
- AbortController used for stop functionality (CHAT-12)
- Partial message saved with " [stopped]" suffix on stop
- UI TypeScript compiles clean
</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/server exec -- tsc --noEmit` passes (or pre-existing non-chat errors only)
- Server routes include stream, edit, truncate endpoints
- Client hook manages full SSE lifecycle
</verification>
<success_criteria>
- Tokens stream from server to client via SSE (CHAT-01)
- Stop generation aborts the connection and saves partial content (CHAT-12)
- SSE headers flushed before generation begins (PERF-02)
- Edit and truncate server endpoints ready for Plan 03 UI
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,497 @@
---
phase: 22-agent-streaming
plan: "02"
type: execute
wave: 1
depends_on: ["22-00"]
files_modified:
- ui/src/components/ChatAgentSelector.tsx
- ui/src/components/ChatMessageIdentityBar.tsx
- ui/src/components/ChatStreamingCursor.tsx
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatAgentSelector.test.tsx
- ui/src/components/ChatMessageIdentityBar.test.tsx
autonomous: true
requirements:
- AGENT-04
- CHAT-08
- THEME-03
must_haves:
truths:
- "Every assistant message shows the agent's name and icon above the content"
- "User can switch the active agent for a conversation via a dropdown selector"
- "Agent colors are visually distinguishable using role-specific Tailwind classes with dark: variants"
artifacts:
- path: "ui/src/components/ChatAgentSelector.tsx"
provides: "Agent dropdown in ChatPanel header"
exports: ["ChatAgentSelector"]
- path: "ui/src/components/ChatMessageIdentityBar.tsx"
provides: "Agent icon + name + timestamp above assistant messages"
exports: ["ChatMessageIdentityBar"]
- path: "ui/src/components/ChatStreamingCursor.tsx"
provides: "Blinking inline cursor during streaming"
exports: ["ChatStreamingCursor"]
- path: "ui/src/components/ChatMessage.tsx"
provides: "Extended ChatMessage with identity bar, streaming cursor, hover actions"
exports: ["ChatMessage"]
key_links:
- from: "ui/src/components/ChatMessageIdentityBar.tsx"
to: "ui/src/lib/agent-role-colors.ts"
via: "import agentRoleColors"
pattern: "agentRoleColors"
- from: "ui/src/components/ChatAgentSelector.tsx"
to: "ui/src/api/agents.ts"
via: "agentsApi.list"
pattern: "agentsApi"
- from: "ui/src/components/ChatMessage.tsx"
to: "ui/src/components/ChatMessageIdentityBar.tsx"
via: "import ChatMessageIdentityBar"
pattern: "ChatMessageIdentityBar"
---
<objective>
Agent identity components: agent selector dropdown (CHAT-08), message identity bar with icon/name/timestamp (AGENT-04), streaming cursor, and role-specific colors (THEME-03). Extends ChatMessage to accept and render agent identity props.
Purpose: Make agent identity visible on every assistant message and allow users to switch agents per conversation.
Output: ChatAgentSelector, ChatMessageIdentityBar, ChatStreamingCursor components; extended ChatMessage.
</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-00-SUMMARY.md
<interfaces>
From ui/src/components/AgentIconPicker.tsx:
```typescript
interface AgentIconProps {
icon?: string | null;
className?: string;
}
export function AgentIcon({ icon, className }: AgentIconProps): JSX.Element;
```
From ui/src/api/agents.ts:
```typescript
export const agentsApi = {
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
// ...
};
```
From packages/shared/src/types/chat.ts:
```typescript
export interface ChatMessage {
id: string; conversationId: string;
role: "user" | "assistant" | "system";
content: string; agentId: string | null;
createdAt: string; updatedAt: string | null;
}
```
From ui/src/lib/agent-role-colors.ts (created in Plan 00):
```typescript
export const agentRoleColors: Record<AgentRole, string>;
export const agentRoleColorDefault: string;
```
From ui/src/components/ChatMessage.tsx (current):
```typescript
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
}
export function ChatMessage({ role, content }: ChatMessageProps): JSX.Element;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage</name>
<read_first>
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatMarkdownMessage.tsx
- ui/src/components/AgentIconPicker.tsx
- ui/src/lib/agent-role-colors.ts
- ui/src/lib/status-colors.ts
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 72-95 identity bar + streaming cursor)
</read_first>
<files>
ui/src/components/ChatMessageIdentityBar.tsx,
ui/src/components/ChatStreamingCursor.tsx,
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatMessageIdentityBar.test.tsx
</files>
<action>
**1. Create `ui/src/components/ChatMessageIdentityBar.tsx`:**
```typescript
import { AgentIcon } from "./AgentIconPicker";
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
import type { AgentRole } from "@paperclipai/shared";
interface ChatMessageIdentityBarProps {
agentName: string;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
}
export function ChatMessageIdentityBar({
agentName,
agentIcon,
agentRole,
timestamp,
isStreaming,
}: ChatMessageIdentityBarProps) {
const colorClass = agentRole ? (agentRoleColors[agentRole] ?? agentRoleColorDefault) : agentRoleColorDefault;
return (
<div className="flex items-center gap-2 mb-1">
<AgentIcon icon={agentIcon} className={`h-4 w-4 ${colorClass}`} />
<span className={`text-[13px] font-semibold ${colorClass}`}>{agentName}</span>
{isStreaming && (
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400 animate-pulse" />
)}
{timestamp && (
<span className="text-[11px] text-muted-foreground">
{new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
);
}
```
Per UI spec: icon 16x16 (`h-4 w-4`), name 13px semibold, timestamp 11px muted, streaming dot uses `bg-cyan-400 animate-pulse` from `agentStatusDot.running`.
**2. Create `ui/src/components/ChatStreamingCursor.tsx`:**
```typescript
export function ChatStreamingCursor() {
return (
<span
className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom"
aria-hidden="true"
/>
);
}
```
Per UI spec: `w-2 h-[1em] bg-foreground/70 animate-cursor-blink`, `aria-hidden="true"` (decorative only).
**3. Extend `ChatMessage` props and rendering in `ui/src/components/ChatMessage.tsx`:**
Update the `ChatMessageProps` interface to:
```typescript
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
}
```
Import `AgentRole` from `@paperclipai/shared`, `ChatMessageIdentityBar`, and `ChatStreamingCursor`.
Update the assistant/system rendering branch to:
```tsx
return (
<div className="max-w-full group relative">
{agentName && (
<ChatMessageIdentityBar
agentName={agentName}
agentIcon={agentIcon}
agentRole={agentRole}
timestamp={timestamp}
isStreaming={isStreaming}
/>
)}
<ChatMarkdownMessage content={content} />
{isStreaming && <ChatStreamingCursor />}
</div>
);
```
The `group` class enables hover-reveal for edit/retry buttons (Plan 03). User message branch remains unchanged for now (edit action is Plan 03).
**4. Replace test stubs in `ui/src/components/ChatMessageIdentityBar.test.tsx`** with real tests:
```typescript
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
describe("ChatMessageIdentityBar", () => {
it("renders agent name in semibold text", () => {
render(<ChatMessageIdentityBar agentName="PM Agent" />);
expect(screen.getByText("PM Agent")).toBeDefined();
});
it("renders timestamp when provided", () => {
render(<ChatMessageIdentityBar agentName="Test" timestamp="2026-01-01T12:30:00Z" />);
// Should contain formatted time
const el = screen.getByText(/12:30/);
expect(el).toBeDefined();
});
it("applies role-specific color class", () => {
const { container } = render(
<ChatMessageIdentityBar agentName="PM" agentRole="pm" />
);
const nameEl = container.querySelector(".font-semibold");
expect(nameEl?.className).toContain("text-blue-600");
expect(nameEl?.className).toContain("dark:text-blue-400");
});
it("shows streaming indicator dot when isStreaming", () => {
const { container } = render(
<ChatMessageIdentityBar agentName="Test" isStreaming={true} />
);
const dot = container.querySelector(".animate-pulse");
expect(dot).toBeDefined();
});
});
```
If `@testing-library/react` is not installed, use `createRoot` + `container.querySelector` pattern from `ChatInput.test.tsx`.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessageIdentityBar.tsx
- grep -q "agentRoleColors" ui/src/components/ChatMessageIdentityBar.tsx
- grep -q "ChatStreamingCursor" ui/src/components/ChatStreamingCursor.tsx
- grep -q "aria-hidden" ui/src/components/ChatStreamingCursor.tsx
- grep -q "animate-cursor-blink" ui/src/components/ChatStreamingCursor.tsx
- grep -q "agentName" ui/src/components/ChatMessage.tsx
- grep -q "agentRole" ui/src/components/ChatMessage.tsx
- grep -q "isStreaming" ui/src/components/ChatMessage.tsx
- grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessage.tsx
- grep -q "ChatStreamingCursor" ui/src/components/ChatMessage.tsx
- grep -q "group" ui/src/components/ChatMessage.tsx
</acceptance_criteria>
<done>
- ChatMessageIdentityBar renders icon (h-4 w-4), agent name (13px semibold), timestamp (11px muted), and streaming dot
- Agent name and icon use role-specific Tailwind color classes from agentRoleColors
- ChatStreamingCursor is inline block with cursor-blink animation and aria-hidden
- ChatMessage accepts agentName, agentIcon, agentRole, timestamp, isStreaming props
- Assistant messages render identity bar when agentName is present
- Assistant messages show streaming cursor when isStreaming is true
- Tests pass for ChatMessageIdentityBar
</done>
</task>
<task type="auto">
<name>Task 2: ChatAgentSelector component</name>
<read_first>
- ui/src/api/agents.ts
- ui/src/components/AgentIconPicker.tsx
- ui/src/api/chat.ts
- ui/src/hooks/useChatConversations.ts
- ui/src/components/ChatAgentSelector.test.tsx
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 58-69 agent selector layout)
</read_first>
<files>
ui/src/components/ChatAgentSelector.tsx,
ui/src/components/ChatAgentSelector.test.tsx
</files>
<action>
**1. Create `ui/src/components/ChatAgentSelector.tsx`:**
```typescript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { agentsApi } from "../api/agents";
import { chatApi } from "../api/chat";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
import type { Agent, AgentRole } from "@paperclipai/shared";
import { useState } from "react";
interface ChatAgentSelectorProps {
companyId: string;
conversationId: string | null;
agentId: string | null;
onAgentChange: (agentId: string | null) => void;
}
export function ChatAgentSelector({
companyId,
conversationId,
agentId,
onAgentChange,
}: ChatAgentSelectorProps) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { data: agents, isLoading } = useQuery({
queryKey: ["agents", companyId],
queryFn: () => agentsApi.list(companyId),
enabled: !!companyId,
});
const updateMutation = useMutation({
mutationFn: (newAgentId: string) =>
chatApi.updateConversation(conversationId!, { agentId: newAgentId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
const activeAgent = agents?.find((a) => a.id === agentId);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
if (conversationId) {
updateMutation.mutate(agent.id);
}
setOpen(false);
};
if (isLoading) {
return <Skeleton className="h-7 w-20 rounded" />;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 max-w-[120px] gap-1 px-2 text-xs"
aria-label="Active agent"
>
{activeAgent ? (
<>
<AgentIcon
icon={activeAgent.icon}
className={`h-3.5 w-3.5 ${activeAgent.role ? (agentRoleColors[activeAgent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault}`}
/>
<span className="truncate">{activeAgent.name}</span>
</>
) : (
<span className="text-muted-foreground">Select agent</span>
)}
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty>No agents configured</CommandEmpty>
<CommandGroup>
{agents?.map((agent) => {
const colorClass = agent.role
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
: agentRoleColorDefault;
return (
<CommandItem
key={agent.id}
onSelect={() => handleSelect(agent)}
className="flex items-center gap-2"
>
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
<span className="truncate">{agent.name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
Per UI spec: trigger shows icon + name, max-w-120px, truncated; popover 200px wide; items show icon + name + role label; "Select agent" placeholder; "No agents configured" empty state. PATCH conversation on selection (optimistic update via onAgentChange callback).
**2. Replace test stubs in `ui/src/components/ChatAgentSelector.test.tsx`:**
Update with real tests. Since the component uses React Query and API calls, test the rendering logic:
```typescript
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
describe("ChatAgentSelector", () => {
it("exports ChatAgentSelector component", async () => {
const mod = await import("./ChatAgentSelector");
expect(mod.ChatAgentSelector).toBeDefined();
expect(typeof mod.ChatAgentSelector).toBe("function");
});
it.todo("renders active agent icon and name when agentId is set");
it.todo("renders 'Select agent' placeholder when no agent selected");
it.todo("lists all workspace agents in dropdown");
it.todo("calls onAgentChange with new agentId on selection");
it.todo("shows 'No agents configured' when agent list is empty");
});
```
Keep most tests as todo since full integration tests require QueryClientProvider mocking. The export test confirms the component loads without errors.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "ChatAgentSelector" ui/src/components/ChatAgentSelector.tsx
- grep -q "aria-label" ui/src/components/ChatAgentSelector.tsx
- grep -q "Active agent" ui/src/components/ChatAgentSelector.tsx
- grep -q "Select agent" ui/src/components/ChatAgentSelector.tsx
- grep -q "No agents configured" ui/src/components/ChatAgentSelector.tsx
- grep -q "agentRoleColors" ui/src/components/ChatAgentSelector.tsx
- grep -q "onAgentChange" ui/src/components/ChatAgentSelector.tsx
- grep -q "updateConversation" ui/src/components/ChatAgentSelector.tsx
- grep -q "max-w-\[120px\]" ui/src/components/ChatAgentSelector.tsx
</acceptance_criteria>
<done>
- ChatAgentSelector renders active agent with icon + name + ChevronDown
- Trigger max-w-120px with truncation
- "Select agent" placeholder when no agent selected
- Popover lists agents with icon, name, and role label
- "No agents configured" empty state
- Selection calls onAgentChange and PATCHes conversation
- Role-specific colors applied to agent icons
- Loading state shows Skeleton
- TypeScript compiles clean
</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose` passes
- All new components export correctly
</verification>
<success_criteria>
- Assistant messages show agent name, icon, and timestamp (AGENT-04)
- Agent icon colors are role-specific with dark: variants (THEME-03)
- Agent selector dropdown allows switching active agent per conversation (CHAT-08)
- Streaming cursor blinks during active generation
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,428 @@
---
phase: 22-agent-streaming
plan: "03"
type: execute
wave: 2
depends_on: ["22-01", "22-02"]
files_modified:
- ui/src/components/ChatMessageActions.tsx
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatStopButton.tsx
- ui/src/components/ChatMessage.test.tsx
autonomous: true
requirements:
- CHAT-10
- CHAT-11
- CHAT-12
must_haves:
truths:
- "User can click edit pencil on a user message to enter inline edit mode"
- "User can click retry on an assistant message to regenerate the response"
- "Stop button appears during streaming and cancels generation on click"
- "Edit/retry buttons are hidden while a stream is active"
artifacts:
- path: "ui/src/components/ChatMessageActions.tsx"
provides: "Edit and Retry hover action buttons"
exports: ["ChatMessageActions"]
- path: "ui/src/components/ChatStopButton.tsx"
provides: "Stop generating button"
exports: ["ChatStopButton"]
- path: "ui/src/components/ChatMessage.tsx"
provides: "Extended ChatMessage with edit mode, retry, actions"
contains: "ChatMessageActions"
key_links:
- from: "ui/src/components/ChatMessage.tsx"
to: "ui/src/components/ChatMessageActions.tsx"
via: "import ChatMessageActions"
pattern: "ChatMessageActions"
- from: "ui/src/components/ChatMessageActions.tsx"
to: "parent callbacks"
via: "onEdit, onRetry props"
pattern: "onEdit|onRetry"
---
<objective>
Message action controls: edit button on user messages (CHAT-10), retry button on assistant messages (CHAT-11), stop generation button (CHAT-12), and inline edit mode for user messages. These are the interactive controls that work with the streaming infrastructure from Plan 01.
Purpose: Give users full control over message lifecycle — edit, retry, stop.
Output: ChatMessageActions, ChatStopButton components; ChatMessage with inline edit mode.
</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 ui/src/components/ChatMessage.tsx (after Plan 02):
```typescript
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
}
```
From ui/src/hooks/useStreamingChat.ts (Plan 01):
```typescript
export function useStreamingChat(conversationId: string | null): {
streamingContent: string;
isStreaming: boolean;
startStream: (userMessage: string, agentId?: string) => void;
stop: () => void;
};
```
From ui/src/api/chat.ts (Plan 01 additions):
```typescript
// chatApi methods available:
chatApi.postMessage(conversationId, data)
chatApi.updateConversation(conversationId, data)
// Plan 01 additions:
chatApi.postMessageAndStream(conversationId, data, callbacks, signal)
chatApi.savePartialMessage(conversationId, data)
```
From server/src/routes/chat.ts (Plan 01):
```
PATCH /conversations/:id/messages/:msgId — edit message content
DELETE /conversations/:id/messages/after/:msgId — truncate messages after
POST /conversations/:id/stream — SSE streaming
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: ChatStopButton and ChatMessageActions components</name>
<read_first>
- ui/src/components/ChatMessage.tsx
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 96-120 stop/edit/retry)
</read_first>
<files>
ui/src/components/ChatStopButton.tsx,
ui/src/components/ChatMessageActions.tsx
</files>
<action>
**1. Create `ui/src/components/ChatStopButton.tsx`:**
```typescript
import { Square } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ChatStopButtonProps {
onStop: () => void;
}
export function ChatStopButton({ onStop }: ChatStopButtonProps) {
return (
<div className="flex justify-center py-2 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={onStop}
aria-label="Stop generating response"
className="gap-1.5"
>
<Square className="h-3 w-3 fill-current" />
Stop generating
</Button>
</div>
);
}
```
Per UI spec: centered, `variant="outline" size="sm"`, `Square` icon (filled via `fill-current`), label "Stop generating", `aria-label="Stop generating response"`. Container has `border-t border-border`.
**2. Create `ui/src/components/ChatMessageActions.tsx`:**
```typescript
import { Pencil, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface ChatMessageActionsProps {
role: "user" | "assistant" | "system";
isStreaming?: boolean;
onEdit?: () => void;
onRetry?: () => void;
}
export function ChatMessageActions({ role, isStreaming, onEdit, onRetry }: ChatMessageActionsProps) {
if (isStreaming) return null;
if (role === "user" && onEdit) {
return (
<div className="absolute top-1 right-1 hidden group-hover:flex">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
aria-label="Edit message"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit message</TooltipContent>
</Tooltip>
</div>
);
}
if (role === "assistant" && onRetry) {
return (
<div className="flex justify-end mt-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hidden group-hover:inline-flex"
onClick={onRetry}
aria-label="Retry response"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Retry response</TooltipContent>
</Tooltip>
</div>
);
}
return null;
}
```
Per UI spec: edit Pencil at top-right of user bubble (absolute positioned, group-hover visible), retry RefreshCw below assistant message (right-aligned, group-hover visible). Both hidden during streaming (`isStreaming` check). Both 14x14px icons (`h-3.5 w-3.5`), `variant="ghost" size="icon"`, with tooltip and aria-label.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "Stop generating" ui/src/components/ChatStopButton.tsx
- grep -q "aria-label" ui/src/components/ChatStopButton.tsx
- grep -q "ChatMessageActions" ui/src/components/ChatMessageActions.tsx
- grep -q "Edit message" ui/src/components/ChatMessageActions.tsx
- grep -q "Retry response" ui/src/components/ChatMessageActions.tsx
- grep -q "group-hover" ui/src/components/ChatMessageActions.tsx
- grep -q "isStreaming" ui/src/components/ChatMessageActions.tsx
</acceptance_criteria>
<done>
- ChatStopButton renders centered outline button with Square icon and "Stop generating" label
- ChatMessageActions renders edit Pencil for user messages (absolute, group-hover)
- ChatMessageActions renders retry RefreshCw for assistant messages (right-aligned, group-hover)
- Both action buttons hidden when isStreaming is true
- All have proper aria-labels and tooltips
- TypeScript compiles clean
</done>
</task>
<task type="auto">
<name>Task 2: Extend ChatMessage with inline edit mode and wire action callbacks</name>
<read_first>
- ui/src/components/ChatMessage.tsx
- ui/src/components/ChatMessageActions.tsx
- ui/src/components/ChatStopButton.tsx
- ui/src/api/chat.ts
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 110-142 edit/retry interaction)
</read_first>
<files>
ui/src/components/ChatMessage.tsx,
ui/src/components/ChatMessage.test.tsx
</files>
<action>
**1. Extend `ChatMessageProps` in `ui/src/components/ChatMessage.tsx`:**
Add to the existing interface:
```typescript
interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
isAnyStreaming?: boolean; // true when ANY message is streaming (disables edit/retry globally)
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
}
```
**2. Add inline edit mode to user message branch:**
```typescript
import { useState } from "react";
import { ChatMessageActions } from "./ChatMessageActions";
import { Button } from "@/components/ui/button";
// Inside ChatMessage component:
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(content);
// User message branch:
if (role === "user") {
if (isEditing) {
return (
<div className="flex justify-end">
<div className="max-w-[85%] w-full">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[40px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Edit your message"
rows={3}
/>
<div className="flex justify-end gap-2 mt-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setIsEditing(false); setEditValue(content); }}
>
Discard edit
</Button>
<Button
variant="default"
size="sm"
disabled={!editValue.trim()}
onClick={() => {
if (id && onEdit && editValue.trim()) {
onEdit(id, editValue.trim());
setIsEditing(false);
}
}}
>
Save edit
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-end">
<div className="relative group max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm">
{content}
<ChatMessageActions
role="user"
isStreaming={isAnyStreaming}
onEdit={() => setIsEditing(true)}
/>
</div>
</div>
);
}
```
Per UI spec: inline textarea pre-filled with content, "Save edit" (variant="default" size="sm", disabled when empty), "Discard edit" (variant="ghost" size="sm"), `aria-label="Edit your message"`.
**3. Update assistant message branch to include retry action:**
```tsx
return (
<div className="max-w-full group relative">
{agentName && (
<ChatMessageIdentityBar
agentName={agentName}
agentIcon={agentIcon}
agentRole={agentRole}
timestamp={timestamp}
isStreaming={isStreaming}
/>
)}
<ChatMarkdownMessage content={content} />
{isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"
isStreaming={isAnyStreaming}
onRetry={id && onRetry ? () => onRetry(id) : undefined}
/>
</div>
);
```
**4. Replace test stubs in `ui/src/components/ChatMessage.test.tsx`:**
```typescript
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
describe("ChatMessage", () => {
it("exports ChatMessage component", async () => {
const mod = await import("./ChatMessage");
expect(mod.ChatMessage).toBeDefined();
});
it.todo("renders user message as right-aligned bubble with plain text");
it.todo("renders assistant message with ChatMarkdownMessage");
it.todo("renders ChatMessageIdentityBar for assistant messages when agentName is provided");
it.todo("shows edit pencil on hover for user messages");
it.todo("shows retry button on hover for assistant messages");
it.todo("hides retry button when isAnyStreaming is true");
it.todo("switches to inline edit textarea on pencil click");
it.todo("renders ChatStreamingCursor when isStreaming is true");
it.todo("Save edit button disabled when edit textarea is empty");
it.todo("Discard edit reverts to read-only bubble");
});
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "isEditing" ui/src/components/ChatMessage.tsx
- grep -q "Save edit" ui/src/components/ChatMessage.tsx
- grep -q "Discard edit" ui/src/components/ChatMessage.tsx
- grep -q "Edit your message" ui/src/components/ChatMessage.tsx
- grep -q "onEdit" ui/src/components/ChatMessage.tsx
- grep -q "onRetry" ui/src/components/ChatMessage.tsx
- grep -q "isAnyStreaming" ui/src/components/ChatMessage.tsx
- grep -q "ChatMessageActions" ui/src/components/ChatMessage.tsx
</acceptance_criteria>
<done>
- ChatMessage user messages show edit pencil on hover (group-hover)
- Edit pencil click opens inline textarea with "Save edit" / "Discard edit" buttons
- Save edit is disabled when textarea empty; calls onEdit(id, newContent) on click
- Discard edit reverts to read-only bubble
- Assistant messages show retry RefreshCw on hover; calls onRetry(id) on click
- All edit/retry actions hidden when isAnyStreaming is true
- TypeScript compiles clean
</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
</verification>
<success_criteria>
- User messages have edit capability with inline textarea (CHAT-10)
- Assistant messages have retry button (CHAT-11)
- Stop button component ready for ChatPanel integration (CHAT-12)
- All actions disabled during active streaming
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`
</output>

View file

@ -0,0 +1,446 @@
---
phase: 22-agent-streaming
plan: "04"
type: execute
wave: 2
depends_on: ["22-00"]
files_modified:
- ui/src/components/ChatSlashCommandPopover.tsx
- ui/src/components/ChatMentionPopover.tsx
- ui/src/lib/slash-commands.ts
- ui/src/components/ChatSlashCommandPopover.test.tsx
- ui/src/components/ChatMentionPopover.test.tsx
autonomous: true
requirements:
- INPUT-05
- INPUT-06
must_haves:
truths:
- "Typing / as first character in ChatInput opens the slash command popover"
- "Typing @ in ChatInput opens the agent mention popover"
- "Selecting a slash command inserts the command prefix into the textarea"
- "Selecting an @mention inserts @agentName into the textarea"
- "/search command is shown but greyed out with 'Coming soon' suffix"
artifacts:
- path: "ui/src/components/ChatSlashCommandPopover.tsx"
provides: "Slash command menu UI"
exports: ["ChatSlashCommandPopover"]
- path: "ui/src/components/ChatMentionPopover.tsx"
provides: "Agent @mention autocomplete UI"
exports: ["ChatMentionPopover"]
- path: "ui/src/lib/slash-commands.ts"
provides: "Slash command definitions and routing table"
exports: ["SLASH_COMMANDS", "resolveAgentFromContent"]
key_links:
- from: "ui/src/lib/slash-commands.ts"
to: "@paperclipai/shared constants"
via: "AgentRole type for routing"
pattern: "AgentRole"
- from: "ui/src/components/ChatSlashCommandPopover.tsx"
to: "ui/src/lib/slash-commands.ts"
via: "import SLASH_COMMANDS"
pattern: "SLASH_COMMANDS"
---
<objective>
Slash command popover (INPUT-05) and @mention popover (INPUT-06). These are standalone components that will be wired into ChatInput in Plan 05. Also creates the slash command routing utility that ChatPanel will use to resolve agent from message content.
Purpose: Enable slash commands and @mentions for routing messages to specific agents.
Output: ChatSlashCommandPopover, ChatMentionPopover components; slash-commands utility with routing logic.
</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-00-SUMMARY.md
<interfaces>
From packages/shared/src/constants.ts:
```typescript
export const AGENT_ROLES = ["pm", "engineer", "ceo", "general", "designer", "qa", "researcher", "devops", "cto", "cmo", "cfo"] as const;
export type AgentRole = (typeof AGENT_ROLES)[number];
```
From ui/src/components/AgentIconPicker.tsx:
```typescript
export function AgentIcon({ icon, className }: { icon?: string | null; className?: string }): JSX.Element;
```
From packages/shared types:
```typescript
interface Agent { id: string; name: string; role: AgentRole; icon: string | null; /* ... */ }
```
From 22-RESEARCH.md Pattern 5:
```typescript
const SLASH_COMMAND_ROUTES: Record<string, AgentRole | null> = {
"/brainstorm": "general",
"/ask-pm": "pm",
"/ask-engineer": "engineer",
"/task": "pm",
"/search": null, // Phase 22 stub
};
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Slash command routing utility and ChatSlashCommandPopover</name>
<read_first>
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 122-140 slash commands)
- .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 5 slash command routing)
- ui/src/components/ChatInput.tsx
</read_first>
<files>
ui/src/lib/slash-commands.ts,
ui/src/components/ChatSlashCommandPopover.tsx,
ui/src/components/ChatSlashCommandPopover.test.tsx
</files>
<action>
**1. Create `ui/src/lib/slash-commands.ts`:**
```typescript
import type { AgentRole } from "@paperclipai/shared";
export interface SlashCommand {
command: string;
description: string;
routesTo: AgentRole | null;
disabled?: boolean;
}
export const SLASH_COMMANDS: SlashCommand[] = [
{ command: "/brainstorm", description: "Route to Brainstormer", routesTo: "general" },
{ command: "/ask-pm", description: "Route to PM", routesTo: "pm" },
{ command: "/ask-engineer", description: "Route to Engineer", routesTo: "engineer" },
{ command: "/task", description: "Create a task", routesTo: "pm" },
{ command: "/search", description: "Search conversations", routesTo: null, disabled: true },
];
/**
* Resolves which agent should receive a message based on slash command prefix or @mention.
* Returns the agent ID to route to, or the active agent ID if no routing override found.
*/
export function resolveAgentFromContent(
content: string,
agents: Array<{ id: string; name: string; role: string }>,
activeAgentId: string | null,
): string | null {
// Slash command takes highest priority
const slashMatch = content.match(/^(\/\S+)/);
if (slashMatch) {
const cmd = slashMatch[1];
const slashCmd = SLASH_COMMANDS.find((c) => c.command === cmd);
if (slashCmd?.routesTo) {
const agent = agents.find((a) => a.role === slashCmd.routesTo);
if (agent) return agent.id;
}
}
// @mention takes second priority
const mentionMatch = content.match(/@(\S+)/);
if (mentionMatch) {
const name = mentionMatch[1]!.toLowerCase();
const agent = agents.find((a) => a.name.toLowerCase().startsWith(name));
if (agent) return agent.id;
}
return activeAgentId;
}
```
**2. Create `ui/src/components/ChatSlashCommandPopover.tsx`:**
```typescript
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { SLASH_COMMANDS, type SlashCommand } from "../lib/slash-commands";
import { cn } from "../lib/utils";
interface ChatSlashCommandPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (command: string) => void;
query: string;
children: React.ReactNode;
}
export function ChatSlashCommandPopover({
open,
onOpenChange,
onSelect,
query,
children,
}: ChatSlashCommandPopoverProps) {
const filtered = SLASH_COMMANDS.filter((cmd) =>
cmd.command.toLowerCase().includes(query.toLowerCase()),
);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
className="w-[260px] p-0"
align="start"
side="top"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandList>
<CommandEmpty>No matching commands</CommandEmpty>
<CommandGroup>
{filtered.map((cmd) => (
<CommandItem
key={cmd.command}
disabled={cmd.disabled}
onSelect={() => {
if (!cmd.disabled) {
onSelect(cmd.command);
onOpenChange(false);
}
}}
className={cn("flex flex-col items-start", cmd.disabled && "opacity-50")}
>
<span className="text-sm font-medium">{cmd.command}</span>
<span className="text-[13px] text-muted-foreground">
{cmd.description}
{cmd.disabled && " (Coming soon)"}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
Per UI spec: 260px wide, opens upward (`side="top"`), items show command + description, `/search` greyed out with "Coming soon" suffix. `onOpenAutoFocus` prevented so textarea keeps focus.
**3. Replace test stubs in `ui/src/components/ChatSlashCommandPopover.test.tsx`:**
```typescript
import { describe, it, expect } from "vitest";
import { SLASH_COMMANDS, resolveAgentFromContent } from "../lib/slash-commands";
describe("slash-commands", () => {
it("defines 5 slash commands", () => {
expect(SLASH_COMMANDS).toHaveLength(5);
});
it("/search is disabled", () => {
const search = SLASH_COMMANDS.find((c) => c.command === "/search");
expect(search?.disabled).toBe(true);
});
it("resolveAgentFromContent routes /ask-pm to pm agent", () => {
const agents = [
{ id: "a1", name: "PM", role: "pm" },
{ id: "a2", name: "Eng", role: "engineer" },
];
expect(resolveAgentFromContent("/ask-pm hello", agents, null)).toBe("a1");
});
it("resolveAgentFromContent routes @mention to matching agent", () => {
const agents = [
{ id: "a1", name: "PM Agent", role: "pm" },
{ id: "a2", name: "Engineer", role: "engineer" },
];
expect(resolveAgentFromContent("Hey @engineer help", agents, null)).toBe("a2");
});
it("resolveAgentFromContent returns activeAgentId when no match", () => {
const agents = [{ id: "a1", name: "PM", role: "pm" }];
expect(resolveAgentFromContent("just a message", agents, "fallback-id")).toBe("fallback-id");
});
});
describe("ChatSlashCommandPopover", () => {
it("exports ChatSlashCommandPopover component", async () => {
const mod = await import("./ChatSlashCommandPopover");
expect(mod.ChatSlashCommandPopover).toBeDefined();
});
});
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- grep -q "SLASH_COMMANDS" ui/src/lib/slash-commands.ts
- grep -q "resolveAgentFromContent" ui/src/lib/slash-commands.ts
- grep -q "/brainstorm" ui/src/lib/slash-commands.ts
- grep -q "/search" ui/src/lib/slash-commands.ts
- grep -q "Coming soon" ui/src/components/ChatSlashCommandPopover.tsx
- grep -q "w-\[260px\]" ui/src/components/ChatSlashCommandPopover.tsx
- grep -q "side=\"top\"" ui/src/components/ChatSlashCommandPopover.tsx
</acceptance_criteria>
<done>
- SLASH_COMMANDS array with 5 commands, /search disabled
- resolveAgentFromContent resolves slash commands first, then @mentions, then falls back to active agent
- ChatSlashCommandPopover renders 260px popover opening upward with command list
- Disabled commands shown greyed with "Coming soon"
- Tests pass for routing logic
</done>
</task>
<task type="auto">
<name>Task 2: ChatMentionPopover component</name>
<read_first>
- ui/src/components/AgentIconPicker.tsx
- ui/src/lib/agent-role-colors.ts
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 139-148 mention popover)
- ui/src/components/ChatMentionPopover.test.tsx
</read_first>
<files>
ui/src/components/ChatMentionPopover.tsx,
ui/src/components/ChatMentionPopover.test.tsx
</files>
<action>
**1. Create `ui/src/components/ChatMentionPopover.tsx`:**
```typescript
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { AgentIcon } from "./AgentIconPicker";
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
import type { Agent, AgentRole } from "@paperclipai/shared";
import { Skeleton } from "@/components/ui/skeleton";
interface ChatMentionPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (agentName: string) => void;
query: string;
agents: Agent[];
isLoading?: boolean;
children: React.ReactNode;
}
export function ChatMentionPopover({
open,
onOpenChange,
onSelect,
query,
agents,
isLoading,
children,
}: ChatMentionPopoverProps) {
const filtered = agents.filter((a) =>
a.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
className="w-[200px] p-1"
align="start"
side="top"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{isLoading ? (
<div className="space-y-1 p-1">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
</div>
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No agents found</p>
) : (
<div className="max-h-[180px] overflow-auto">
{filtered.slice(0, 5).map((agent) => {
const colorClass = agent.role
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
: agentRoleColorDefault;
return (
<button
key={agent.id}
className="flex items-center gap-2 w-full rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
onClick={() => {
onSelect(agent.name);
onOpenChange(false);
}}
>
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
<span className="truncate">{agent.name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
</button>
);
})}
</div>
)}
</PopoverContent>
</Popover>
);
}
```
Per UI spec: 200px wide, opens upward, each row has icon (14x14, `h-3.5 w-3.5`) + name + role label in muted text, max 5 visible, "No agents found" empty state, 3 skeleton rows loading state. `onOpenAutoFocus` prevented to keep textarea focus.
**2. Replace test stubs in `ui/src/components/ChatMentionPopover.test.tsx`:**
```typescript
import { describe, it, expect } from "vitest";
describe("ChatMentionPopover", () => {
it("exports ChatMentionPopover component", async () => {
const mod = await import("./ChatMentionPopover");
expect(mod.ChatMentionPopover).toBeDefined();
});
it.todo("renders agent list filtered by query string");
it.todo("shows agent icon, name, and role for each item");
it.todo("calls onSelect with agent name on item click");
it.todo("shows 'No agents found' when filter matches nothing");
});
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "ChatMentionPopover" ui/src/components/ChatMentionPopover.tsx
- grep -q "No agents found" ui/src/components/ChatMentionPopover.tsx
- grep -q "w-\[200px\]" ui/src/components/ChatMentionPopover.tsx
- grep -q "side=\"top\"" ui/src/components/ChatMentionPopover.tsx
- grep -q "agentRoleColors" ui/src/components/ChatMentionPopover.tsx
- grep -q "onSelect" ui/src/components/ChatMentionPopover.tsx
- grep -q "Skeleton" ui/src/components/ChatMentionPopover.tsx
</acceptance_criteria>
<done>
- ChatMentionPopover renders 200px upward popover with agent list
- Agents filtered by query (case-insensitive)
- Each row shows icon (with role color), name (truncated), and role label (muted)
- Max 5 agents visible before scroll
- "No agents found" empty state
- 3 skeleton rows loading state
- Selecting an agent calls onSelect(agentName) and closes popover
- TypeScript compiles clean
</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose` passes
</verification>
<success_criteria>
- 5 slash commands available with /search greyed out (INPUT-05)
- @mention popover shows agents filtered by query (INPUT-06)
- resolveAgentFromContent routes messages based on /command or @mention
- Both popovers open upward, anchored to textarea, with proper widths
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`
</output>

View file

@ -0,0 +1,833 @@
---
phase: 22-agent-streaming
plan: "05"
type: execute
wave: 3
depends_on: ["22-01", "22-02", "22-03", "22-04"]
files_modified:
- ui/src/components/ChatMessageList.tsx
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatInput.tsx
- ui/src/hooks/useChatMessages.ts
- ui/src/api/chat.ts
- ui/src/components/ChatMessageList.test.tsx
autonomous: false
requirements:
- PERF-03
- CHAT-01
- CHAT-08
- CHAT-10
- CHAT-11
- CHAT-12
- INPUT-05
- INPUT-06
- PERF-02
must_haves:
truths:
- "Messages render through a virtualized list with only visible items in the DOM"
- "Streaming message appended as synthetic entry in the virtualizer"
- "ChatPanel integrates agent selector, stop button, streaming, edit/retry, slash commands, and @mentions"
- "User can send a message and see tokens appear in real time"
- "User can stop, edit, or retry messages"
- "Slash commands and @mentions route to the correct agent"
artifacts:
- path: "ui/src/components/ChatMessageList.tsx"
provides: "Virtualized message list with streaming message overlay"
contains: "useVirtualizer"
- path: "ui/src/components/ChatPanel.tsx"
provides: "Fully wired ChatPanel with all Phase 22 features"
contains: "useStreamingChat"
- path: "ui/src/components/ChatInput.tsx"
provides: "ChatInput with slash command and @mention popovers"
contains: "ChatSlashCommandPopover"
key_links:
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/hooks/useStreamingChat.ts"
via: "import useStreamingChat"
pattern: "useStreamingChat"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/ChatAgentSelector.tsx"
via: "import ChatAgentSelector"
pattern: "ChatAgentSelector"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/ChatStopButton.tsx"
via: "import ChatStopButton"
pattern: "ChatStopButton"
- from: "ui/src/components/ChatMessageList.tsx"
to: "@tanstack/react-virtual"
via: "useVirtualizer"
pattern: "useVirtualizer"
- from: "ui/src/components/ChatInput.tsx"
to: "ui/src/components/ChatSlashCommandPopover.tsx"
via: "import ChatSlashCommandPopover"
pattern: "ChatSlashCommandPopover"
- from: "ui/src/components/ChatInput.tsx"
to: "ui/src/components/ChatMentionPopover.tsx"
via: "import ChatMentionPopover"
pattern: "ChatMentionPopover"
---
<objective>
Final integration plan: virtualize the message list (PERF-03), wire all Phase 22 components into ChatPanel and ChatInput, and add edit/retry API methods to chatApi. This plan connects every piece built in Plans 01-04 into a working end-to-end experience.
Purpose: Deliver the complete Phase 22 feature set as a wired, working system.
Output: Virtualized ChatMessageList, fully integrated ChatPanel, ChatInput with popovers, chat API edit/truncate methods.
</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
@.planning/phases/22-agent-streaming/22-03-SUMMARY.md
@.planning/phases/22-agent-streaming/22-04-SUMMARY.md
<interfaces>
From ui/src/hooks/useStreamingChat.ts (Plan 01):
```typescript
export function useStreamingChat(conversationId: string | null): {
streamingContent: string;
isStreaming: boolean;
startStream: (userMessage: string, agentId?: string) => void;
stop: () => void;
};
```
From ui/src/components/ChatAgentSelector.tsx (Plan 02):
```typescript
interface ChatAgentSelectorProps {
companyId: string;
conversationId: string | null;
agentId: string | null;
onAgentChange: (agentId: string | null) => void;
}
export function ChatAgentSelector(props: ChatAgentSelectorProps): JSX.Element;
```
From ui/src/components/ChatStopButton.tsx (Plan 03):
```typescript
export function ChatStopButton({ onStop }: { onStop: () => void }): JSX.Element;
```
From ui/src/components/ChatMessage.tsx (Plan 03):
```typescript
interface ChatMessageProps {
id?: string; role: "user" | "assistant" | "system"; content: string;
agentName?: string | null; agentIcon?: string | null; agentRole?: AgentRole | null;
timestamp?: string; isStreaming?: boolean; isAnyStreaming?: boolean;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
}
```
From ui/src/components/ChatSlashCommandPopover.tsx (Plan 04):
```typescript
interface ChatSlashCommandPopoverProps {
open: boolean; onOpenChange: (open: boolean) => void;
onSelect: (command: string) => void; query: string; children: React.ReactNode;
}
```
From ui/src/components/ChatMentionPopover.tsx (Plan 04):
```typescript
interface ChatMentionPopoverProps {
open: boolean; onOpenChange: (open: boolean) => void;
onSelect: (agentName: string) => void; query: string;
agents: Agent[]; isLoading?: boolean; children: React.ReactNode;
}
```
From ui/src/lib/slash-commands.ts (Plan 04):
```typescript
export function resolveAgentFromContent(
content: string,
agents: Array<{ id: string; name: string; role: string }>,
activeAgentId: string | null,
): string | null;
```
From ui/src/hooks/useChatMessages.ts:
```typescript
export function useChatMessages(conversationId: string | null): {
messages: ChatMessage[]; isLoading: boolean; sendMutation: UseMutationResult;
// ... infinite query props
};
```
From ui/src/api/chat.ts:
```typescript
export const chatApi = {
listConversations, createConversation, getConversation,
updateConversation, deleteConversation, listMessages, postMessage,
postMessageAndStream, savePartialMessage,
};
```
From server routes (Plan 01):
```
PATCH /conversations/:id/messages/:msgId
DELETE /conversations/:id/messages/after/:msgId
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Virtualized ChatMessageList and chat API edit/truncate methods</name>
<read_first>
- ui/src/components/ChatMessageList.tsx
- ui/src/hooks/useChatMessages.ts
- ui/src/api/chat.ts
- .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 3 virtualizer)
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 341-349 virtualizer)
</read_first>
<files>
ui/src/components/ChatMessageList.tsx,
ui/src/api/chat.ts,
ui/src/components/ChatMessageList.test.tsx
</files>
<action>
**1. Add edit and truncate methods to `chatApi` in `ui/src/api/chat.ts`:**
```typescript
async editMessage(conversationId: string, messageId: string, content: string) {
return api.patch<ChatMessage>(`/conversations/${conversationId}/messages/${messageId}`, { content });
},
async truncateMessagesAfter(conversationId: string, messageId: string) {
await fetch(`/api/conversations/${conversationId}/messages/after/${messageId}`, {
method: "DELETE",
credentials: "include",
});
},
```
**2. Rewrite `ui/src/components/ChatMessageList.tsx` with virtualizer:**
Replace the entire file with a virtualized implementation:
```typescript
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useChatMessages } from "../hooks/useChatMessages";
import { ChatMessage } from "./ChatMessage";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import type { ChatMessage as ChatMessageType, AgentRole } from "@paperclipai/shared";
import { useState } from "react";
interface ChatMessageListProps {
conversationId: string;
streamingContent?: string;
isStreaming?: boolean;
streamingAgentName?: string | null;
streamingAgentIcon?: string | null;
streamingAgentRole?: AgentRole | null;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
agentMap?: Map<string, { name: string; icon: string | null; role: AgentRole | null }>;
}
export function ChatMessageList({
conversationId,
streamingContent,
isStreaming,
streamingAgentName,
streamingAgentIcon,
streamingAgentRole,
onEdit,
onRetry,
agentMap,
}: ChatMessageListProps) {
const { messages, isLoading } = useChatMessages(conversationId);
const parentRef = useRef<HTMLDivElement>(null);
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
// Build display list: real messages + optional synthetic streaming message
const displayMessages: Array<ChatMessageType & { isStreamingEntry?: boolean }> = [
...messages,
...(isStreaming && streamingContent
? [{
id: "__streaming__",
conversationId,
role: "assistant" as const,
content: streamingContent,
agentId: null,
createdAt: new Date().toISOString(),
updatedAt: null,
isStreamingEntry: true,
}]
: []),
];
const virtualizer = useVirtualizer({
count: displayMessages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height,
});
// Auto-scroll to bottom when new messages arrive (if user hasn't scrolled up)
useEffect(() => {
if (displayMessages.length > 0 && !showJumpToBottom) {
virtualizer.scrollToIndex(displayMessages.length - 1, { align: "end" });
}
}, [displayMessages.length]);
// Re-measure streaming message as it grows (Pitfall 3 from RESEARCH.md)
useEffect(() => {
if (isStreaming && displayMessages.length > 0) {
virtualizer.measure();
}
}, [streamingContent, isStreaming]);
// Track scroll position for "jump to bottom" button
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowJumpToBottom(distFromBottom > 200);
}, []);
const jumpToBottom = () => {
virtualizer.scrollToIndex(displayMessages.length - 1, { align: "end" });
setShowJumpToBottom(false);
};
if (isLoading) {
return (
<div className="space-y-4 p-3">
<Skeleton className="h-16 w-3/4" />
<Skeleton className="h-12 w-1/2 ml-auto" />
<Skeleton className="h-20 w-3/4" />
</div>
);
}
if (displayMessages.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
</div>
);
}
return (
<div className="relative flex-1">
<div
ref={parentRef}
className="h-full overflow-auto p-3"
onScroll={handleScroll}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((item) => {
const msg = displayMessages[item.index]!;
const agent = msg.agentId && agentMap ? agentMap.get(msg.agentId) : undefined;
const isThisStreaming = "isStreamingEntry" in msg && msg.isStreamingEntry;
return (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
transform: `translateY(${item.start}px)`,
width: "100%",
paddingBottom: "16px",
}}
>
<ChatMessage
id={msg.id}
role={msg.role as "user" | "assistant" | "system"}
content={msg.content}
agentName={agent?.name ?? streamingAgentName}
agentIcon={agent?.icon ?? streamingAgentIcon}
agentRole={agent?.role ?? streamingAgentRole}
timestamp={msg.createdAt}
isStreaming={isThisStreaming}
isAnyStreaming={isStreaming}
onEdit={onEdit}
onRetry={onRetry}
/>
</div>
);
})}
</div>
</div>
{/* Jump to bottom button */}
{showJumpToBottom && (
<div className="absolute bottom-2 right-4">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-full shadow-md"
onClick={jumpToBottom}
aria-label="Scroll to latest message"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
```
Key points:
- `useVirtualizer` with `estimateSize: 80`, `overscan: 5`, dynamic measurement via `measureElement`
- Streaming message appended as synthetic entry with `id: "__streaming__"` and `isStreamingEntry: true`
- `virtualizer.measure()` called on `streamingContent` change to re-measure growing message (Pitfall 3)
- "Jump to bottom" button when scrolled >200px from bottom
- 3 loading skeletons with varying widths
- Agent identity props resolved from `agentMap` or streaming agent props
**3. Update test stubs in `ui/src/components/ChatMessageList.test.tsx`:**
```typescript
import { describe, it, expect } from "vitest";
describe("ChatMessageList", () => {
it("exports ChatMessageList component", async () => {
const mod = await import("./ChatMessageList");
expect(mod.ChatMessageList).toBeDefined();
});
it.todo("renders messages using virtualizer");
it.todo("auto-scrolls to bottom when new messages arrive");
it.todo("shows loading skeleton when isLoading");
it.todo("shows empty state when no messages");
it.todo("appends streaming message as synthetic entry");
it.todo("shows jump-to-bottom button when scrolled up");
});
```
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "useVirtualizer" ui/src/components/ChatMessageList.tsx
- grep -q "measureElement" ui/src/components/ChatMessageList.tsx
- grep -q "__streaming__" ui/src/components/ChatMessageList.tsx
- grep -q "virtualizer.measure" ui/src/components/ChatMessageList.tsx
- grep -q "Scroll to latest message" ui/src/components/ChatMessageList.tsx
- grep -q "estimateSize" ui/src/components/ChatMessageList.tsx
- grep -q "overscan" ui/src/components/ChatMessageList.tsx
- grep -q "editMessage" ui/src/api/chat.ts
- grep -q "truncateMessagesAfter" ui/src/api/chat.ts
</acceptance_criteria>
<done>
- ChatMessageList uses @tanstack/react-virtual useVirtualizer
- Dynamic height measurement via measureElement
- Streaming message rendered as synthetic array entry
- virtualizer.measure() called on streaming content change
- Jump-to-bottom button appears when scrolled >200px from bottom
- 3 loading skeletons shown during load
- chatApi has editMessage and truncateMessagesAfter methods
- TypeScript compiles clean
</done>
</task>
<task type="auto">
<name>Task 2: Wire ChatPanel and ChatInput with all Phase 22 features</name>
<read_first>
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatInput.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/hooks/useStreamingChat.ts
- ui/src/components/ChatAgentSelector.tsx
- ui/src/components/ChatStopButton.tsx
- ui/src/components/ChatSlashCommandPopover.tsx
- ui/src/components/ChatMentionPopover.tsx
- ui/src/lib/slash-commands.ts
- ui/src/api/agents.ts
</read_first>
<files>
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatInput.tsx
</files>
<action>
**1. Rewrite `ui/src/components/ChatInput.tsx` to add slash command and @mention popovers:**
Keep the existing textarea, auto-resize, and keyboard handling. Add:
a) Import `ChatSlashCommandPopover`, `ChatMentionPopover`, `Agent` type.
b) Add new props:
```typescript
interface ChatInputProps {
onSend: (content: string) => void;
isSubmitting?: boolean;
disabled?: boolean;
placeholder?: string;
// Popover support
agents?: Agent[];
agentsLoading?: boolean;
}
```
c) Add state for popovers:
```typescript
const [slashOpen, setSlashOpen] = useState(false);
const [slashQuery, setSlashQuery] = useState("");
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
```
d) Update the `onChange` handler to detect `/` and `@`:
```typescript
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const val = e.target.value;
setValue(val);
// Slash command: opens when / is the first character
if (val.startsWith("/")) {
setSlashOpen(true);
setSlashQuery(val);
} else {
setSlashOpen(false);
}
// @mention: opens when @ appears with a word boundary before it
const mentionMatch = val.match(/@(\w*)$/);
if (mentionMatch) {
setMentionOpen(true);
setMentionQuery(mentionMatch[1] ?? "");
} else {
setMentionOpen(false);
}
}
```
e) Handle slash command selection:
```typescript
function handleSlashSelect(command: string) {
setValue(command + " ");
setSlashOpen(false);
textareaRef.current?.focus();
}
```
f) Handle mention selection:
```typescript
function handleMentionSelect(agentName: string) {
// Replace the @query with @agentName
const val = value.replace(/@\w*$/, `@${agentName} `);
setValue(val);
setMentionOpen(false);
textareaRef.current?.focus();
}
```
g) Wrap the form in a relative div and add popover components:
- `ChatSlashCommandPopover` wraps the textarea as trigger
- `ChatMentionPopover` wraps the textarea as trigger
- Use only ONE popover active at a time (slash takes priority)
Per UI spec: popovers open upward from textarea, dismissed on Escape or clicking outside. Placeholder changes to "Waiting for response..." when disabled (per `placeholder` prop).
**2. Rewrite `ui/src/components/ChatPanel.tsx` to integrate all Phase 22 features:**
The new ChatPanel needs:
- `useStreamingChat` hook for streaming
- `ChatAgentSelector` in header
- `ChatStopButton` above input when streaming
- Agent resolution from slash commands / @mentions
- Edit and retry handlers
- Agent data for identity bars
```typescript
import { useState, useMemo } from "react";
import { X } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useChatPanel } from "../context/ChatPanelContext";
import { useCompany } from "../context/CompanyContext";
import { ChatInput } from "./ChatInput";
import { ChatConversationList } from "./ChatConversationList";
import { ChatMessageList } from "./ChatMessageList";
import { ChatAgentSelector } from "./ChatAgentSelector";
import { ChatStopButton } from "./ChatStopButton";
import { Button } from "@/components/ui/button";
import { chatApi } from "../api/chat";
import { agentsApi } from "../api/agents";
import { useChatMessages } from "../hooks/useChatMessages";
import { useStreamingChat } from "../hooks/useStreamingChat";
import { resolveAgentFromContent } from "../lib/slash-commands";
import type { AgentRole } from "@paperclipai/shared";
export function ChatPanel() {
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [isSending, setIsSending] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const { sendMutation } = useChatMessages(activeConversationId);
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
// Load agents for routing and identity
const { data: agents = [], isLoading: agentsLoading } = useQuery({
queryKey: ["agents", selectedCompanyId],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Build agent map for message identity bars
const agentMap = useMemo(() => {
const map = new Map<string, { name: string; icon: string | null; role: AgentRole | null }>();
for (const a of agents) {
map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null });
}
return map;
}, [agents]);
// Resolve streaming agent identity
const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined;
const handleSend = async (content: string) => {
if (!selectedCompanyId) return;
// Resolve agent from slash command or @mention
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
setIsSending(true);
try {
if (!activeConversationId) {
// Path 1: No active conversation — create one, post user message, then stream
const newConvo = await chatApi.createConversation(selectedCompanyId, {
agentId: resolvedAgentId ?? undefined,
});
setActiveConversationId(newConvo.id);
await chatApi.postMessage(newConvo.id, { role: "user", content });
queryClient.invalidateQueries({ queryKey: ["chat"] });
// Note: streaming starts on next render when activeConversationId is set
// For now, the echo stream will be triggered by the new conversation
} else {
// Path 2: Active conversation — post user message then stream
await chatApi.postMessage(activeConversationId, { role: "user", content });
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(content, resolvedAgentId ?? undefined);
}
} finally {
setIsSending(false);
}
};
// Edit handler: update message, truncate after it, re-stream
const handleEdit = async (messageId: string, newContent: string) => {
if (!activeConversationId) return;
await chatApi.editMessage(activeConversationId, messageId, newContent);
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(newContent, activeAgentId ?? undefined);
};
// Retry handler: truncate from the assistant message onward, re-stream the previous user message
const handleRetry = async (messageId: string) => {
if (!activeConversationId) return;
// Truncate the assistant message and everything after
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
// Also delete the message itself
// For retry, we re-stream using the last user message content
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
// The last user message is the one before the deleted assistant message
// Re-trigger stream with empty content (server echo will use whatever was sent)
startStream("Regenerate this response", activeAgentId ?? undefined);
};
return (
<aside
aria-label="Chat"
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
style={{ width: chatOpen ? 380 : 0 }}
>
{/* Header with agent selector */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
<span className="text-sm font-medium">Chat</span>
<div className="flex items-center gap-2">
{selectedCompanyId && (
<ChatAgentSelector
companyId={selectedCompanyId}
conversationId={activeConversationId}
agentId={activeAgentId}
onAgentChange={setActiveAgentId}
/>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setChatOpen(false)}
aria-label="Close chat"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Two-column layout */}
<div className="flex flex-1 min-h-0 min-w-[380px]">
{/* Left column: conversation list */}
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
{selectedCompanyId ? (
<ChatConversationList companyId={selectedCompanyId} />
) : (
<div className="p-3 text-center text-xs text-muted-foreground">
No workspace selected
</div>
)}
</div>
{/* Right column: message thread + stop button + input */}
<div className="flex flex-1 flex-col min-w-0">
{/* Message area */}
<div className="flex-1 overflow-hidden">
{activeConversationId ? (
<ChatMessageList
conversationId={activeConversationId}
streamingContent={streamingContent}
isStreaming={isStreaming}
streamingAgentName={streamingAgent?.name ?? null}
streamingAgentIcon={streamingAgent?.icon ?? null}
streamingAgentRole={streamingAgent?.role ?? null}
onEdit={handleEdit}
onRetry={handleRetry}
agentMap={agentMap}
/>
) : (
<div className="flex items-center justify-center h-full p-3">
<p className="text-sm text-muted-foreground text-center">
Send a message to start this conversation.
</p>
</div>
)}
</div>
{/* Stop button (shown during streaming) */}
{isStreaming && <ChatStopButton onStop={stop} />}
{/* Input area */}
<div className="border-t border-border px-3 py-2">
<ChatInput
onSend={handleSend}
isSubmitting={isSending}
disabled={isStreaming}
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
agents={agents}
agentsLoading={agentsLoading}
/>
</div>
</div>
</div>
</aside>
);
}
```
Key integration points:
- `useStreamingChat` provides streamingContent, isStreaming, startStream, stop
- `ChatAgentSelector` in header with `onAgentChange` updating local state
- `ChatStopButton` shown conditionally when `isStreaming`
- `ChatInput` receives `agents` for mention popover, `disabled` during streaming, custom placeholder
- `ChatMessageList` receives streaming props and agentMap for identity bars
- `handleEdit` calls editMessage + truncateMessagesAfter + startStream
- `handleRetry` calls truncateMessagesAfter + startStream
- `resolveAgentFromContent` determines which agent receives the message
- `ScrollArea` replaced by virtualizer's own scroll container in ChatMessageList
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "useStreamingChat" ui/src/components/ChatPanel.tsx
- grep -q "ChatAgentSelector" ui/src/components/ChatPanel.tsx
- grep -q "ChatStopButton" ui/src/components/ChatPanel.tsx
- grep -q "resolveAgentFromContent" ui/src/components/ChatPanel.tsx
- grep -q "handleEdit" ui/src/components/ChatPanel.tsx
- grep -q "handleRetry" ui/src/components/ChatPanel.tsx
- grep -q "agentMap" ui/src/components/ChatPanel.tsx
- grep -q "streamingContent" ui/src/components/ChatPanel.tsx
- grep -q "ChatSlashCommandPopover" ui/src/components/ChatInput.tsx
- grep -q "ChatMentionPopover" ui/src/components/ChatInput.tsx
- grep -q "slashOpen" ui/src/components/ChatInput.tsx
- grep -q "mentionOpen" ui/src/components/ChatInput.tsx
- grep -q "placeholder" ui/src/components/ChatInput.tsx
</acceptance_criteria>
<done>
- ChatPanel integrates: useStreamingChat, ChatAgentSelector, ChatStopButton, agent routing, edit/retry handlers
- ChatInput has slash command popover (triggered by / at start) and @mention popover (triggered by @)
- Streaming content passed to ChatMessageList as synthetic entry
- Agent identity resolved from agentMap for message identity bars
- Edit handler: editMessage + truncateMessagesAfter + re-stream
- Retry handler: truncateMessagesAfter + re-stream
- Input disabled during streaming with "Waiting for response..." placeholder
- Stop button appears during streaming
- Agent selector in header for per-conversation agent switching
- TypeScript compiles clean
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify complete Phase 22 feature set</name>
<what-built>Complete Phase 22 agent streaming feature: SSE streaming with echo stub, agent selector, message identity bars with role colors, edit/retry/stop controls, slash commands, @mentions, and virtualized message list.</what-built>
<how-to-verify>
1. Start the dev server: `pnpm dev`
2. Open the chat panel (click chat icon in sidebar)
3. Send a message — verify tokens stream in word-by-word (echo stub)
4. During streaming, verify the blinking cursor appears at the end
5. Click "Stop generating" during a stream — verify partial message saved with [stopped] suffix
6. Hover a user message — verify edit pencil appears; click it, edit, save — verify response regenerates
7. Hover an assistant message — verify retry button appears; click — verify regeneration
8. Use the agent selector in the header to switch agents
9. Type `/` at start of input — verify slash command popover opens; select `/ask-pm`
10. Type `@` in input — verify agent mention popover opens
11. Verify agent name and colored icon appear above assistant messages
12. Switch between all 3 themes — verify agent colors remain distinguishable
13. Load a conversation with many messages — verify smooth scrolling (virtualized)
</how-to-verify>
<resume-signal>Type "approved" or describe issues</resume-signal>
<action>Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above.</action>
<verify><automated>pnpm --filter @paperclipai/ui vitest run --reporter=verbose</automated></verify>
<done>All 13 verification steps pass visual/functional inspection</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
- All components wired and rendering
</verification>
<success_criteria>
- Tokens stream from server to client in real time (CHAT-01, PERF-02)
- Agent selector allows switching active agent (CHAT-08)
- Edit previous message triggers regeneration (CHAT-10)
- Retry button regenerates assistant response (CHAT-11)
- Stop button cancels streaming and preserves partial content (CHAT-12)
- Slash commands route to correct agent (INPUT-05)
- @mentions route to named agent (INPUT-06)
- Agent identity bar with role colors on every assistant message (AGENT-04, THEME-03)
- 1,000+ messages scroll via virtualized list (PERF-03)
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-05-SUMMARY.md`
</output>