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

33 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",
  });
},

Also add a deleteMessage method for retry (needed to delete the assistant message itself):

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:

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

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 { 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<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: 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 (
    <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 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 <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
    • 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) </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 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

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