--- phase: 22-agent-streaming plan: 03 type: execute wave: 2 depends_on: [22-01, 22-02] files_modified: - ui/src/api/chat.ts - ui/src/hooks/useChatMessages.ts - ui/src/hooks/useChatConversations.ts - ui/src/components/ChatMessageList.tsx - ui/src/components/ChatInput.tsx - ui/src/components/ChatPanel.tsx autonomous: true requirements: [CHAT-01, CHAT-11, CHAT-12, PERF-02, PERF-03, INPUT-05, INPUT-06, AGENT-04, CHAT-08, CHAT-10] must_haves: truths: - "User sends a message and sees tokens stream in real-time as an assistant message bubble" - "User can click Stop to cancel an in-progress stream" - "User can click Retry on any assistant message to regenerate the response" - "User can edit a previous user message and trigger regeneration" - "Agent selector in chat panel header switches the active agent for the conversation" - "Agent badge shows above each assistant message with colored avatar and name" - "Slash commands route messages to the correct agent for that single message" - "@mention routes to the named agent for that single message" - "1000+ messages render without jank using virtua VList" - "Slash command popover appears when typing / in the input" artifacts: - path: "ui/src/hooks/useChatMessages.ts" provides: "useStreamMessage hook with streaming state, partialContent, stop/send/retry/edit" exports: ["useStreamMessage", "useEditMessage"] - path: "ui/src/components/ChatMessageList.tsx" provides: "Virtualized message list with VList, agent badges, edit/retry buttons, streaming indicator" contains: "VList" - path: "ui/src/components/ChatInput.tsx" provides: "Stop button during streaming, slash command popover, @mention popover" contains: "Square" - path: "ui/src/components/ChatPanel.tsx" provides: "AgentSelector in header, streaming state threading" contains: "AgentSelector" key_links: - from: "ui/src/hooks/useChatMessages.ts" to: "ui/src/api/chat.ts" via: "chatApi.sendMessage + EventSource for streaming" pattern: "EventSource|chatApi" - from: "ui/src/components/ChatMessageList.tsx" to: "ui/src/components/ChatAgentBadge.tsx" via: "import { ChatAgentBadge }" pattern: "ChatAgentBadge" - from: "ui/src/components/ChatInput.tsx" to: "ui/src/lib/parseMessageIntent.ts" via: "import { parseMessageIntent, SLASH_COMMANDS }" pattern: "parseMessageIntent" - from: "ui/src/components/ChatPanel.tsx" to: "ui/src/components/AgentSelector.tsx" via: "import { AgentSelector }" pattern: "AgentSelector" --- Wire all Phase 22 pieces together: streaming hook with EventSource, virtualized ChatMessageList with agent badges and action buttons, ChatInput with stop/popover/parsing, and ChatPanel integration with AgentSelector. Purpose: This is the integration plan that connects the server SSE endpoint (Plan 01) with the UI components (Plan 02) into a working streaming chat experience. Output: Complete streaming chat with agent selection, edit/retry, stop generation, slash commands, @mentions, and virtualized scrolling. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/22-agent-streaming/22-RESEARCH.md @.planning/phases/22-agent-streaming/22-UI-SPEC.md @.planning/phases/22-agent-streaming/22-01-SUMMARY.md @.planning/phases/22-agent-streaming/22-02-SUMMARY.md SSE stream endpoint: ``` GET /api/conversations/:id/stream?triggerMessageId=X Response: text/event-stream Events: data: { "type": "token", "content": "word" } data: { "type": "done", "messageId": "uuid" } data: { "type": "error", "message": "..." } ``` Edit message endpoint: ``` PUT /api/conversations/:id/messages/:messageId Body: { "content": "edited text" } Response: ChatMessage with editedContent, editedAt set ``` PATCH conversation with agentId: ``` PATCH /api/conversations/:id Body: { "agentId": "uuid" } Response: ChatConversation with agentId updated ``` Updated ChatMessage type: ```typescript interface ChatMessage { id: string; conversationId: string; role: "user" | "assistant" | "system"; content: string; agentId: string | null; editedContent: string | null; editedAt: string | null; createdAt: string; } ``` ```typescript // ui/src/lib/agent-colors.ts export function agentRoleColorClass(role: string): string; // ui/src/lib/parseMessageIntent.ts export const SLASH_COMMANDS: Record; export interface MessageIntent { text: string; targetRole?: string; targetName?: string; } export function parseMessageIntent(content: string): MessageIntent; // ui/src/components/ChatAgentBadge.tsx export function ChatAgentBadge({ agentId, agents }: { agentId: string | null; agents: Agent[] }): JSX.Element; // ui/src/components/AgentSelector.tsx export function AgentSelector({ agents, currentAgentId, onSelect, isLoading }: {...}): JSX.Element; ``` From ui/src/api/chat.ts: ```typescript export const chatApi = { sendMessage: (conversationId, data) => api.post(...), updateConversation: (id, data) => api.patch(...), listMessages: (conversationId, opts) => api.get<{ items: ChatMessage[]; hasMore: boolean }>(...), // ... }; ``` From ui/src/hooks/useChatMessages.ts: ```typescript export function useChatMessages(conversationId: string | null); // useInfiniteQuery export function useSendMessage(conversationId: string | null); // useMutation ``` From ui/src/hooks/useChatConversations.ts: ```typescript export function useConversationActions(); // returns pin/unpin/archive/remove/rename mutations ``` From ui/src/context/ChatPanelContext.tsx: ```typescript export function useChatPanel(); // { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } ``` From ui/src/lib/queryKeys.ts: ```typescript agents: { list: (companyId: string) => ["agents", companyId] as const } ``` virtua API: ```typescript import { VList } from "virtua"; // {children} // ref.current.scrollToIndex(index, { smooth: false }) // onScroll callback provides scrollOffset, scrollSize, viewportSize ``` Task 1: Install virtua + API client additions + useStreamMessage hook + useEditMessage hook + useUpdateConversationAgent ui/package.json, ui/src/api/chat.ts, ui/src/hooks/useChatMessages.ts, ui/src/hooks/useChatConversations.ts ui/src/api/chat.ts, ui/src/hooks/useChatMessages.ts, ui/src/hooks/useChatConversations.ts, ui/src/context/ChatPanelContext.tsx, ui/src/api/client.ts, .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 2: EventSource Hook) 0. **Install virtua:** ```bash pnpm --filter @paperclipai/ui add virtua ``` 1. **Extend `ui/src/api/chat.ts`** -- Add these methods to the `chatApi` object: ```typescript editMessage: (conversationId: string, messageId: string, data: { content: string }) => api.put(`/api/conversations/${conversationId}/messages/${messageId}`, data), updateConversationAgent: (id: string, agentId: string) => api.patch(`/api/conversations/${id}`, { agentId }), ``` 2. **Extend `ui/src/hooks/useChatMessages.ts`** -- Add `useStreamMessage` hook: ```typescript export function useStreamMessage(conversationId: string | null) { const queryClient = useQueryClient(); const [streaming, setStreaming] = useState(false); const [partialContent, setPartialContent] = useState(""); const esRef = useRef(null); const stop = useCallback(() => { esRef.current?.close(); esRef.current = null; setStreaming(false); setPartialContent(""); }, []); const send = useCallback(async (content: string, agentId?: string | null) => { if (!conversationId || streaming) return; // Step 1: POST user message via existing API const userMsg = await chatApi.sendMessage(conversationId, { role: "user", content, agentId: agentId ?? undefined, }); // Invalidate to show user message immediately queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); // Step 2: Open SSE stream for assistant response setStreaming(true); setPartialContent(""); const source = new EventSource( `/api/conversations/${conversationId}/stream?triggerMessageId=${userMsg.id}` ); esRef.current = source; source.onmessage = (event) => { try { const parsed = JSON.parse(event.data) as { type: string; content?: string; messageId?: string; message?: string }; if (parsed.type === "token" && parsed.content) { setPartialContent((prev) => prev + parsed.content); } else if (parsed.type === "done") { source.close(); esRef.current = null; setStreaming(false); setPartialContent(""); // Refresh message list to show persisted assistant message queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); } else if (parsed.type === "error") { source.close(); esRef.current = null; setStreaming(false); setPartialContent(""); // Toast would go here -- for now log console.error("Stream error:", parsed.message); } } catch { // Ignore parse errors on SSE comments like `:ok` } }; source.onerror = () => { source.close(); esRef.current = null; setStreaming(false); setPartialContent(""); }; }, [conversationId, streaming, queryClient]); const retry = useCallback(async (agentId?: string | null) => { if (!conversationId || streaming) return; // Retry: open stream without posting a new message -- server re-generates from last user message setStreaming(true); setPartialContent(""); const source = new EventSource( `/api/conversations/${conversationId}/stream` ); esRef.current = source; source.onmessage = (event) => { try { const parsed = JSON.parse(event.data); if (parsed.type === "token" && parsed.content) { setPartialContent((prev) => prev + parsed.content); } else if (parsed.type === "done") { source.close(); esRef.current = null; setStreaming(false); setPartialContent(""); queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); } } catch { /* ignore */ } }; source.onerror = () => { source.close(); esRef.current = null; setStreaming(false); setPartialContent(""); }; }, [conversationId, streaming, queryClient]); // Cleanup on unmount useEffect(() => { return () => { esRef.current?.close(); }; }, []); return { streaming, partialContent, send, stop, retry }; } ``` Add `useEditMessage` hook: ```typescript export function useEditMessage(conversationId: string | null) { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ messageId, content }: { messageId: string; content: string }) => chatApi.editMessage(conversationId!, messageId, { content }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); }, }); } ``` Add required imports at top: `useState, useCallback, useRef, useEffect` from react. 3. **Extend `ui/src/hooks/useChatConversations.ts`** -- Add `useUpdateConversationAgent` hook: ```typescript export function useUpdateConversationAgent() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ conversationId, agentId }: { conversationId: string; agentId: string }) => chatApi.updateConversationAgent(conversationId, agentId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); }, }); } ``` Add import for `chatApi` (should already be imported; if not, add `import { chatApi } from "../api/chat";`). pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run - grep -q "virtua" ui/package.json returns 0 - grep -q "editMessage" ui/src/api/chat.ts returns 0 - grep -q "updateConversationAgent" ui/src/api/chat.ts returns 0 - grep -q "useStreamMessage" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "useEditMessage" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "EventSource" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "useUpdateConversationAgent" ui/src/hooks/useChatConversations.ts returns 0 - grep -q "partialContent" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "streaming" ui/src/hooks/useChatMessages.ts returns 0 - pnpm --filter @paperclipai/ui build exits 0 - pnpm --filter @paperclipai/ui test run exits 0 virtua installed, API client extended with editMessage + updateConversationAgent, useStreamMessage hook with EventSource streaming + stop + retry, useEditMessage mutation, useUpdateConversationAgent mutation, build and tests pass. Note: useStreamMessage is tested via the full integration in Plan 04's visual checkpoint rather than unit tests, since EventSource requires complex browser mocking -- the hook's logic is straightforward state management over a well-tested SSE endpoint. Task 2: ChatMessageList virtualization + agent badges + action buttons + ChatInput streaming/popover + ChatPanel AgentSelector integration ui/src/components/ChatMessageList.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatPanel.tsx ui/src/components/ChatMessageList.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatPanel.tsx, ui/src/components/ChatAgentBadge.tsx (from Plan 02), ui/src/components/AgentSelector.tsx (from Plan 02), ui/src/lib/parseMessageIntent.ts (from Plan 02), ui/src/hooks/useChatMessages.ts (just updated in Task 1), ui/src/hooks/useChatConversations.ts (just updated in Task 1), ui/src/components/ui/command.tsx, ui/src/components/ui/popover.tsx, .planning/phases/22-agent-streaming/22-UI-SPEC.md (full Interaction Contract + Component Inventory) NOTE: This task touches 3 tightly-coupled components that share streaming state. They are kept in one task because splitting would create artificial seams -- ChatPanel owns the state that ChatMessageList and ChatInput consume. Implement in order: A (ChatMessageList), B (ChatInput), C (ChatPanel wiring). **A. Rewrite `ChatMessageList.tsx`** to use virtua VList with agent badges and action buttons: Replace the entire component. New props interface: ```typescript interface ChatMessageListProps { conversationId: string; streaming: boolean; partialContent: string; agents: Agent[]; onRetry: () => void; onEditMessage: (messageId: string, content: string) => void; } ``` Implementation: 1. Import `VList` from `virtua` and create a `listRef = useRef(null)` (import `VListHandle` type from virtua). 2. Replace the outer `
` with: ```tsx
{allMessages.map((msg) => ( ))} {streaming && partialContent && ( )} {!isAtBottom && ( )}
``` 3. Track `isAtBottom` state: use VList's `onScroll` callback. Virtua's VList provides `onScroll` with the scroll offset. Calculate: `isAtBottom = (event.scrollOffset + event.viewportSize >= event.scrollSize - 80)`. Initialize `isAtBottom` to `true`. 4. Auto-scroll during streaming: when `streaming` is true and `isAtBottom`, after each partialContent change, call `listRef.current?.scrollToIndex(allMessages.length, { smooth: false })` via a useEffect. 5. Keep `role="log"` and `aria-live="polite"` on an outer wrapper div (not the VList itself -- VList is the scroll container). **MessageItem** (inline component or extracted): ```tsx function MessageItem({ message, agents, streaming, onRetry, onEdit }) { const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(message.content); return (
{/* Agent badge for assistant messages */} {message.role === "assistant" && ( )} {/* Message bubble */}
{editing ? (