nexus/.planning/phases/22-agent-streaming/22-05-PLAN.md

31 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
22-agent-streaming 05 execute 3
22-01
22-02
22-03
22-04
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
false
PERF-03
CHAT-01
CHAT-08
CHAT-10
CHAT-11
CHAT-12
INPUT-05
INPUT-06
PERF-02
truths artifacts key_links
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
path provides contains
ui/src/components/ChatMessageList.tsx Virtualized message list with streaming message overlay useVirtualizer
path provides contains
ui/src/components/ChatPanel.tsx Fully wired ChatPanel with all Phase 22 features useStreamingChat
path provides contains
ui/src/components/ChatInput.tsx ChatInput with slash command and @mention popovers ChatSlashCommandPopover
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/hooks/useStreamingChat.ts import useStreamingChat useStreamingChat
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/ChatAgentSelector.tsx import ChatAgentSelector ChatAgentSelector
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/ChatStopButton.tsx import ChatStopButton ChatStopButton
from to via pattern
ui/src/components/ChatMessageList.tsx @tanstack/react-virtual useVirtualizer useVirtualizer
from to via pattern
ui/src/components/ChatInput.tsx ui/src/components/ChatSlashCommandPopover.tsx import ChatSlashCommandPopover ChatSlashCommandPopover
from to via pattern
ui/src/components/ChatInput.tsx ui/src/components/ChatMentionPopover.tsx import ChatMentionPopover 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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):

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):

export function ChatStopButton({ onStop }: { onStop: () => void }): JSX.Element;

From ui/src/components/ChatMessage.tsx (Plan 03):

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):

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):

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):

export function resolveAgentFromContent(
  content: string,
  agents: Array<{ id: string; name: string; role: string }>,
  activeAgentId: string | null,
): string | null;

From ui/src/hooks/useChatMessages.ts:

export function useChatMessages(conversationId: string | null): {
  messages: ChatMessage[]; isLoading: boolean; sendMutation: UseMutationResult;
  // ... infinite query props
};

From ui/src/api/chat.ts:

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`:**
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:

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:

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:

interface ChatInputProps {
  onSend: (content: string) => void;
  isSubmitting?: boolean;
  disabled?: boolean;
  placeholder?: string;
  // Popover support
  agents?: Agent[];
  agentsLoading?: boolean;
}

c) Add state for popovers:

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 @:

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:

function handleSlashSelect(command: string) {
  setValue(command + " ");
  setSlashOpen(false);
  textareaRef.current?.focus();
}

f) Handle mention selection:

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
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 pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 <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>
    • 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

<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>
After completion, create `.planning/phases/22-agent-streaming/22-05-SUMMARY.md`