---
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"
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<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

<interfaces>
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
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Virtualized ChatMessageList and chat API edit/truncate methods</name>
  <read_first>
    - 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)
  </read_first>
  <files>
    ui/src/components/ChatMessageList.tsx,
    ui/src/api/chat.ts,
    ui/src/components/ChatMessageList.test.tsx
  </files>
  <action>
**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<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):
```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<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`:**
```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");
});
```
  </action>
  <verify>
    <automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    - 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
  </done>
</task>

<task type="auto">
  <name>Task 2: Wire ChatPanel and ChatInput with all Phase 22 features</name>
  <read_first>
    - 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
  </read_first>
  <files>
    ui/src/components/ChatPanel.tsx,
    ui/src/components/ChatInput.tsx
  </files>
  <action>
**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<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:
   ```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<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
  </action>
  <verify>
    <automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <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>
  <done>
    - 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
  </done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Verify complete Phase 22 feature set</name>
  <what-built>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.</what-built>
  <how-to-verify>
    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)
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues</resume-signal>
  <action>Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above, testing each of the 9 requirements individually.</action>
  <verify><automated>pnpm --filter @paperclipai/ui vitest run --reporter=verbose</automated></verify>
  <done>All 12 verification steps pass visual/functional inspection, with each of the 9 requirements individually confirmed</done>
</task>

</tasks>

<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
- All components wired and rendering
</verification>

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

<output>
After completion, create `.planning/phases/22-agent-streaming/22-05-SUMMARY.md`
</output>
