--- 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", }); }, ``` **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 and truncateMessagesAfter 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/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 { 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(); 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 ( ); } ``` 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 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 - 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 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. 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) Type "approved" or describe issues Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above. pnpm --filter @paperclipai/ui vitest run --reporter=verbose All 13 verification steps pass visual/functional inspection - `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 (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`
Send a message to start this conversation.