--- 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" --- 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. @$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 @.planning/phases/22-agent-streaming/22-03-SUMMARY.md @.planning/phases/22-agent-streaming/22-04-SUMMARY.md 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 ``` Task 1: Virtualized ChatMessageList and chat API edit/truncate methods - 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) ui/src/components/ChatMessageList.tsx, ui/src/api/chat.ts, ui/src/components/ChatMessageList.test.tsx **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(`/conversations/${conversationId}/messages/${messageId}`, { content }); }, async truncateMessagesAfter(conversationId: string, messageId: string) { await fetch(`/api/conversations/${conversationId}/messages/after/${messageId}`, { method: "DELETE", credentials: "include", }); }, ``` Also add a `deleteMessage` method for retry (needed to delete the assistant message itself): ```typescript async deleteMessage(conversationId: string, messageId: string) { await fetch(`/api/conversations/${conversationId}/messages/${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; } export function ChatMessageList({ conversationId, streamingContent, isStreaming, streamingAgentName, streamingAgentIcon, streamingAgentRole, onEdit, onRetry, agentMap, }: ChatMessageListProps) { const { messages, isLoading } = useChatMessages(conversationId); const parentRef = useRef(null); const [showJumpToBottom, setShowJumpToBottom] = useState(false); // Build display list: real messages + optional synthetic streaming message const displayMessages: Array = [ ...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 (
); } if (displayMessages.length === 0) { return (

Send a message to start this conversation.

); } return (
{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 (
); })}
{/* Jump to bottom button */} {showJumpToBottom && (
)}
); } ``` 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"); }); ```
pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 - 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 - 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, truncateMessagesAfter, and deleteMessage methods - TypeScript compiles clean
Task 2: Wire ChatPanel and ChatInput with all Phase 22 features - 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 - ui/src/hooks/useChatMessages.ts ui/src/components/ChatPanel.tsx, ui/src/components/ChatInput.tsx **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) { 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(null); const { messages } = 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(); 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: find the last user message before the assistant message, // delete the assistant message and everything after it, then re-stream // with the actual prior user message content (not hardcoded text). const handleRetry = async (assistantMessageId: string) => { if (!activeConversationId || !messages) return; // Find the assistant message index in the messages array const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId); if (assistantIdx < 0) return; // Find the last user message before this assistant message let lastUserContent = ""; for (let i = assistantIdx - 1; i >= 0; i--) { if (messages[i]!.role === "user") { lastUserContent = messages[i]!.content; break; } } if (!lastUserContent) return; // No prior user message found; nothing to retry // Truncate messages after the user message (this deletes the assistant msg + everything after) // First, find the user message to truncate after let userMessageId = ""; for (let i = assistantIdx - 1; i >= 0; i--) { if (messages[i]!.role === "user") { userMessageId = messages[i]!.id; break; } } if (!userMessageId) return; // Delete everything after the user message (includes the assistant message itself) await chatApi.truncateMessagesAfter(activeConversationId, userMessageId); queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); // Re-stream using the actual user message content startStream(lastUserContent, activeAgentId ?? undefined); }; return ( ); } ``` 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 with edited content - `handleRetry` finds the ACTUAL prior user message content (not hardcoded text), truncates from that user message onward (which deletes the assistant message), and re-streams with the real user message - `resolveAgentFromContent` determines which agent receives the message - `ScrollArea` replaced by virtualizer's own scroll container in ChatMessageList pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 - 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 - grep -q "lastUserContent" ui/src/components/ChatPanel.tsx (retry uses actual user message) - NOT grep -q "Regenerate this response" ui/src/components/ChatPanel.tsx (no hardcoded retry text) - 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 with edited content - Retry handler: looks up actual last user message content, truncates from user message onward (deleting the assistant message), re-streams with real user content - 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 Task 3: Verify complete Phase 22 feature set 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. All 9 requirements (PERF-03, CHAT-01, CHAT-08, CHAT-10, CHAT-11, CHAT-12, INPUT-05, INPUT-06, PERF-02) must be individually exercised: 1. **CHAT-01** (streaming): Send a message -- verify tokens stream in word-by-word (echo stub) 2. **PERF-02** (latency): During streaming, verify the blinking cursor appears at the end promptly (sub-100ms first token from echo stub) 3. **CHAT-12** (stop): Click "Stop generating" during a stream -- verify partial message saved with [stopped] suffix 4. **CHAT-10** (edit): Hover a user message -- verify edit pencil appears; click it, edit, save -- verify response regenerates with new content 5. **CHAT-11** (retry): Hover an assistant message -- verify retry button appears; click -- verify it regenerates using the PRIOR user message (not hardcoded text) 6. **CHAT-08** (agent selector): Use the agent selector in the header to switch agents; verify new messages are attributed to the selected agent 7. **INPUT-05** (slash commands): Type `/` at start of input -- verify slash command popover opens; select `/ask-pm`; verify the command routes to PM agent 8. **INPUT-06** (@mention): Type `@` in input -- verify agent mention popover opens; select an agent; verify routing 9. **PERF-03** (virtualization): Load a conversation with many messages -- verify smooth scrolling (check DOM has limited rendered nodes via DevTools) Additional visual checks: 10. Verify agent name and colored icon appear above assistant messages (AGENT-04) 11. Switch between all 3 themes -- verify agent colors remain distinguishable (THEME-03) 12. Verify all 11 agent roles show distinct colors (no two roles share the same color) Type "approved" or describe issues Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above, testing each of the 9 requirements individually. pnpm --filter @paperclipai/ui vitest run --reporter=verbose All 12 verification steps pass visual/functional inspection, with each of the 9 requirements individually confirmed
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes - `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes - All components wired and rendering - 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 using actual prior user message (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) After completion, create `.planning/phases/22-agent-streaming/22-05-SUMMARY.md`