nexus/.planning/phases/22-agent-streaming/22-03-PLAN.md
Mikkel Georgsen e8c70d6c8d [nexus] fix(22): revise plans based on checker feedback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:45:00 +02:00

34 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
22-agent-streaming 03 execute 2
22-01
22-02
ui/src/api/chat.ts
ui/src/hooks/useChatMessages.ts
ui/src/hooks/useChatConversations.ts
ui/src/components/ChatMessageList.tsx
ui/src/components/ChatInput.tsx
ui/src/components/ChatPanel.tsx
true
CHAT-01
CHAT-11
CHAT-12
PERF-02
PERF-03
INPUT-05
INPUT-06
AGENT-04
CHAT-08
CHAT-10
truths artifacts key_links
User sends a message and sees tokens stream in real-time as an assistant message bubble
User can click Stop to cancel an in-progress stream
User can click Retry on any assistant message to regenerate the response
User can edit a previous user message and trigger regeneration
Agent selector in chat panel header switches the active agent for the conversation
Agent badge shows above each assistant message with colored avatar and name
Slash commands route messages to the correct agent for that single message
@mention routes to the named agent for that single message
1000+ messages render without jank using virtua VList
Slash command popover appears when typing / in the input
path provides exports
ui/src/hooks/useChatMessages.ts useStreamMessage hook with streaming state, partialContent, stop/send/retry/edit
useStreamMessage
useEditMessage
path provides contains
ui/src/components/ChatMessageList.tsx Virtualized message list with VList, agent badges, edit/retry buttons, streaming indicator VList
path provides contains
ui/src/components/ChatInput.tsx Stop button during streaming, slash command popover, @mention popover Square
path provides contains
ui/src/components/ChatPanel.tsx AgentSelector in header, streaming state threading AgentSelector
from to via pattern
ui/src/hooks/useChatMessages.ts ui/src/api/chat.ts chatApi.sendMessage + EventSource for streaming EventSource|chatApi
from to via pattern
ui/src/components/ChatMessageList.tsx ui/src/components/ChatAgentBadge.tsx import { ChatAgentBadge } ChatAgentBadge
from to via pattern
ui/src/components/ChatInput.tsx ui/src/lib/parseMessageIntent.ts import { parseMessageIntent, SLASH_COMMANDS } parseMessageIntent
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/AgentSelector.tsx import { AgentSelector } AgentSelector
Wire all Phase 22 pieces together: streaming hook with EventSource, virtualized ChatMessageList with agent badges and action buttons, ChatInput with stop/popover/parsing, and ChatPanel integration with AgentSelector.

Purpose: This is the integration plan that connects the server SSE endpoint (Plan 01) with the UI components (Plan 02) into a working streaming chat experience. Output: Complete streaming chat with agent selection, edit/retry, stop generation, slash commands, @mentions, and virtualized scrolling.

<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 SSE stream endpoint: ``` GET /api/conversations/:id/stream?triggerMessageId=X Response: text/event-stream Events: data: { "type": "token", "content": "word" } data: { "type": "done", "messageId": "uuid" } data: { "type": "error", "message": "..." } ```

Edit message endpoint:

PUT /api/conversations/:id/messages/:messageId
Body: { "content": "edited text" }
Response: ChatMessage with editedContent, editedAt set

PATCH conversation with agentId:

PATCH /api/conversations/:id
Body: { "agentId": "uuid" }
Response: ChatConversation with agentId updated

Updated ChatMessage type:

interface ChatMessage {
  id: string;
  conversationId: string;
  role: "user" | "assistant" | "system";
  content: string;
  agentId: string | null;
  editedContent: string | null;
  editedAt: string | null;
  createdAt: string;
}
// ui/src/lib/agent-colors.ts
export function agentRoleColorClass(role: string): string;

// ui/src/lib/parseMessageIntent.ts
export const SLASH_COMMANDS: Record<string, string>;
export interface MessageIntent { text: string; targetRole?: string; targetName?: string; }
export function parseMessageIntent(content: string): MessageIntent;

// ui/src/components/ChatAgentBadge.tsx
export function ChatAgentBadge({ agentId, agents }: { agentId: string | null; agents: Agent[] }): JSX.Element;

// ui/src/components/AgentSelector.tsx
export function AgentSelector({ agents, currentAgentId, onSelect, isLoading }: {...}): JSX.Element;

From ui/src/api/chat.ts:

export const chatApi = {
  sendMessage: (conversationId, data) => api.post<ChatMessage>(...),
  updateConversation: (id, data) => api.patch<ChatConversation>(...),
  listMessages: (conversationId, opts) => api.get<{ items: ChatMessage[]; hasMore: boolean }>(...),
  // ...
};

From ui/src/hooks/useChatMessages.ts:

export function useChatMessages(conversationId: string | null);  // useInfiniteQuery
export function useSendMessage(conversationId: string | null);   // useMutation

From ui/src/hooks/useChatConversations.ts:

export function useConversationActions();  // returns pin/unpin/archive/remove/rename mutations

From ui/src/context/ChatPanelContext.tsx:

export function useChatPanel();  // { chatOpen, setChatOpen, activeConversationId, setActiveConversationId }

From ui/src/lib/queryKeys.ts:

agents: { list: (companyId: string) => ["agents", companyId] as const }

virtua API:

import { VList } from "virtua";
// <VList ref={ref} style={{ flex: 1 }}>{children}</VList>
// ref.current.scrollToIndex(index, { smooth: false })
// onScroll callback provides scrollOffset, scrollSize, viewportSize
Task 1: Install virtua + API client additions + useStreamMessage hook + useEditMessage hook + useUpdateConversationAgent ui/package.json, ui/src/api/chat.ts, ui/src/hooks/useChatMessages.ts, ui/src/hooks/useChatConversations.ts ui/src/api/chat.ts, ui/src/hooks/useChatMessages.ts, ui/src/hooks/useChatConversations.ts, ui/src/context/ChatPanelContext.tsx, ui/src/api/client.ts, .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 2: EventSource Hook) 0. **Install virtua:** ```bash pnpm --filter @paperclipai/ui add virtua ```
1. **Extend `ui/src/api/chat.ts`** -- Add these methods to the `chatApi` object:
   ```typescript
   editMessage: (conversationId: string, messageId: string, data: { content: string }) =>
     api.put<ChatMessage>(`/api/conversations/${conversationId}/messages/${messageId}`, data),
   updateConversationAgent: (id: string, agentId: string) =>
     api.patch<ChatConversation>(`/api/conversations/${id}`, { agentId }),
   ```

2. **Extend `ui/src/hooks/useChatMessages.ts`** -- Add `useStreamMessage` hook:
   ```typescript
   export function useStreamMessage(conversationId: string | null) {
     const queryClient = useQueryClient();
     const [streaming, setStreaming] = useState(false);
     const [partialContent, setPartialContent] = useState("");
     const esRef = useRef<EventSource | null>(null);

     const stop = useCallback(() => {
       esRef.current?.close();
       esRef.current = null;
       setStreaming(false);
       setPartialContent("");
     }, []);

     const send = useCallback(async (content: string, agentId?: string | null) => {
       if (!conversationId || streaming) return;

       // Step 1: POST user message via existing API
       const userMsg = await chatApi.sendMessage(conversationId, {
         role: "user",
         content,
         agentId: agentId ?? undefined,
       });

       // Invalidate to show user message immediately
       queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
       queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });

       // Step 2: Open SSE stream for assistant response
       setStreaming(true);
       setPartialContent("");

       const source = new EventSource(
         `/api/conversations/${conversationId}/stream?triggerMessageId=${userMsg.id}`
       );
       esRef.current = source;

       source.onmessage = (event) => {
         try {
           const parsed = JSON.parse(event.data) as { type: string; content?: string; messageId?: string; message?: string };
           if (parsed.type === "token" && parsed.content) {
             setPartialContent((prev) => prev + parsed.content);
           } else if (parsed.type === "done") {
             source.close();
             esRef.current = null;
             setStreaming(false);
             setPartialContent("");
             // Refresh message list to show persisted assistant message
             queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
           } else if (parsed.type === "error") {
             source.close();
             esRef.current = null;
             setStreaming(false);
             setPartialContent("");
             // Toast would go here -- for now log
             console.error("Stream error:", parsed.message);
           }
         } catch {
           // Ignore parse errors on SSE comments like `:ok`
         }
       };

       source.onerror = () => {
         source.close();
         esRef.current = null;
         setStreaming(false);
         setPartialContent("");
       };
     }, [conversationId, streaming, queryClient]);

     const retry = useCallback(async (agentId?: string | null) => {
       if (!conversationId || streaming) return;
       // Retry: open stream without posting a new message -- server re-generates from last user message
       setStreaming(true);
       setPartialContent("");

       const source = new EventSource(
         `/api/conversations/${conversationId}/stream`
       );
       esRef.current = source;

       source.onmessage = (event) => {
         try {
           const parsed = JSON.parse(event.data);
           if (parsed.type === "token" && parsed.content) {
             setPartialContent((prev) => prev + parsed.content);
           } else if (parsed.type === "done") {
             source.close();
             esRef.current = null;
             setStreaming(false);
             setPartialContent("");
             queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
           }
         } catch { /* ignore */ }
       };

       source.onerror = () => {
         source.close();
         esRef.current = null;
         setStreaming(false);
         setPartialContent("");
       };
     }, [conversationId, streaming, queryClient]);

     // Cleanup on unmount
     useEffect(() => {
       return () => {
         esRef.current?.close();
       };
     }, []);

     return { streaming, partialContent, send, stop, retry };
   }
   ```

   Add `useEditMessage` hook:
   ```typescript
   export function useEditMessage(conversationId: string | null) {
     const queryClient = useQueryClient();
     return useMutation({
       mutationFn: ({ messageId, content }: { messageId: string; content: string }) =>
         chatApi.editMessage(conversationId!, messageId, { content }),
       onSuccess: () => {
         queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
       },
     });
   }
   ```

   Add required imports at top: `useState, useCallback, useRef, useEffect` from react.

3. **Extend `ui/src/hooks/useChatConversations.ts`** -- Add `useUpdateConversationAgent` hook:
   ```typescript
   export function useUpdateConversationAgent() {
     const queryClient = useQueryClient();
     return useMutation({
       mutationFn: ({ conversationId, agentId }: { conversationId: string; agentId: string }) =>
         chatApi.updateConversationAgent(conversationId, agentId),
       onSuccess: () => {
         queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
       },
     });
   }
   ```

   Add import for `chatApi` (should already be imported; if not, add `import { chatApi } from "../api/chat";`).
pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run - grep -q "virtua" ui/package.json returns 0 - grep -q "editMessage" ui/src/api/chat.ts returns 0 - grep -q "updateConversationAgent" ui/src/api/chat.ts returns 0 - grep -q "useStreamMessage" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "useEditMessage" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "EventSource" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "useUpdateConversationAgent" ui/src/hooks/useChatConversations.ts returns 0 - grep -q "partialContent" ui/src/hooks/useChatMessages.ts returns 0 - grep -q "streaming" ui/src/hooks/useChatMessages.ts returns 0 - pnpm --filter @paperclipai/ui build exits 0 - pnpm --filter @paperclipai/ui test run exits 0 virtua installed, API client extended with editMessage + updateConversationAgent, useStreamMessage hook with EventSource streaming + stop + retry, useEditMessage mutation, useUpdateConversationAgent mutation, build and tests pass. Note: useStreamMessage is tested via the full integration in Plan 04's visual checkpoint rather than unit tests, since EventSource requires complex browser mocking -- the hook's logic is straightforward state management over a well-tested SSE endpoint. Task 2: ChatMessageList virtualization + agent badges + action buttons + ChatInput streaming/popover + ChatPanel AgentSelector integration ui/src/components/ChatMessageList.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatPanel.tsx ui/src/components/ChatMessageList.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatPanel.tsx, ui/src/components/ChatAgentBadge.tsx (from Plan 02), ui/src/components/AgentSelector.tsx (from Plan 02), ui/src/lib/parseMessageIntent.ts (from Plan 02), ui/src/hooks/useChatMessages.ts (just updated in Task 1), ui/src/hooks/useChatConversations.ts (just updated in Task 1), ui/src/components/ui/command.tsx, ui/src/components/ui/popover.tsx, .planning/phases/22-agent-streaming/22-UI-SPEC.md (full Interaction Contract + Component Inventory) NOTE: This task touches 3 tightly-coupled components that share streaming state. They are kept in one task because splitting would create artificial seams -- ChatPanel owns the state that ChatMessageList and ChatInput consume. Implement in order: A (ChatMessageList), B (ChatInput), C (ChatPanel wiring).
**A. Rewrite `ChatMessageList.tsx`** to use virtua VList with agent badges and action buttons:

Replace the entire component. New props interface:
```typescript
interface ChatMessageListProps {
  conversationId: string;
  streaming: boolean;
  partialContent: string;
  agents: Agent[];
  onRetry: () => void;
  onEditMessage: (messageId: string, content: string) => void;
}
```

Implementation:
1. Import `VList` from `virtua` and create a `listRef = useRef<VListHandle>(null)` (import `VListHandle` type from virtua).
2. Replace the outer `<div role="log" ... className="overflow-y-auto flex-1">` with:
   ```tsx
   <div className="relative flex-1 flex flex-col min-h-0">
     <VList ref={listRef} style={{ flex: 1 }} className="p-4">
       {allMessages.map((msg) => (
         <MessageItem
           key={msg.id}
           message={msg}
           agents={agents}
           streaming={streaming}
           onRetry={onRetry}
           onEdit={onEditMessage}
         />
       ))}
       {streaming && partialContent && (
         <StreamingMessage content={partialContent} agents={agents} />
       )}
     </VList>
     {!isAtBottom && (
       <Button
         variant="outline"
         size="sm"
         className="absolute bottom-20 right-4 z-10"
         aria-label="Jump to bottom"
         onClick={() => {
           listRef.current?.scrollToIndex(allMessages.length - 1, { smooth: false });
           setIsAtBottom(true);
         }}
       >
         <ChevronDown className="h-4 w-4" />
       </Button>
     )}
   </div>
   ```
3. Track `isAtBottom` state: use VList's `onScroll` callback. Virtua's VList provides `onScroll` with the scroll offset. Calculate: `isAtBottom = (event.scrollOffset + event.viewportSize >= event.scrollSize - 80)`. Initialize `isAtBottom` to `true`.
4. Auto-scroll during streaming: when `streaming` is true and `isAtBottom`, after each partialContent change, call `listRef.current?.scrollToIndex(allMessages.length, { smooth: false })` via a useEffect.
5. Keep `role="log"` and `aria-live="polite"` on an outer wrapper div (not the VList itself -- VList is the scroll container).

**MessageItem** (inline component or extracted):
```tsx
function MessageItem({ message, agents, streaming, onRetry, onEdit }) {
  const [editing, setEditing] = useState(false);
  const [editValue, setEditValue] = useState(message.content);

  return (
    <div className={cn("group flex flex-col gap-1 mb-4", message.role === "user" ? "items-end" : "items-start")}>
      {/* Agent badge for assistant messages */}
      {message.role === "assistant" && (
        <ChatAgentBadge agentId={message.agentId} agents={agents} />
      )}

      {/* Message bubble */}
      <div className={cn(
        "px-4 py-2 rounded-md text-sm",
        message.role === "user"
          ? "ml-auto bg-secondary text-secondary-foreground max-w-[75%]"
          : "max-w-[85%]",
      )}>
        {editing ? (
          <div className="flex flex-col gap-2">
            <textarea
              value={editValue}
              onChange={(e) => setEditValue(e.target.value)}
              aria-label="Edit message"
              aria-multiline="true"
              className="bg-transparent border-none resize-none text-sm focus:outline-none w-full"
              style={{ minHeight: 40, maxHeight: 120 }}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  onEdit(message.id, editValue);
                  setEditing(false);
                } else if (e.key === "Escape") {
                  setEditing(false);
                  setEditValue(message.content);
                }
              }}
            />
            <Button variant="default" size="sm" onClick={() => { onEdit(message.id, editValue); setEditing(false); }}>
              Regenerate
            </Button>
          </div>
        ) : message.role === "user" ? (
          <span>{message.editedContent ?? message.content}</span>
        ) : (
          <ChatMarkdownMessage content={message.editedContent ?? message.content} />
        )}
      </div>

      {/* Timestamp */}
      <span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
        {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
        {message.editedAt && " (edited)"}
      </span>

      {/* Action buttons -- visible on hover, hidden during streaming */}
      {!streaming && !editing && (
        <div className="flex justify-end gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
          {message.role === "user" && (
            <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Edit message" onClick={() => setEditing(true)}>
              <Pencil className="h-3.5 w-3.5" />
            </Button>
          )}
          {message.role === "assistant" && (
            <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Retry response" onClick={onRetry}>
              <RotateCcw className="h-3.5 w-3.5" />
            </Button>
          )}
        </div>
      )}
    </div>
  );
}
```

**StreamingMessage** (inline):
```tsx
function StreamingMessage({ content, agents }: { content: string; agents: Agent[] }) {
  return (
    <div className="flex flex-col gap-1 items-start mb-4" aria-live="off">
      <div className="max-w-[85%] px-4 py-2 rounded-md text-sm">
        <ChatMarkdownMessage content={content} />
        <span className="inline-block w-2 h-2 rounded-full bg-muted animate-pulse ml-1 align-middle" aria-label="Response streaming" />
      </div>
    </div>
  );
}
```

Imports needed: `VList` from `virtua`, `ChatAgentBadge` from `./ChatAgentBadge`, `ChatMarkdownMessage` from `./ChatMarkdownMessage`, `Button` from `@/components/ui/button`, `Pencil, RotateCcw, ChevronDown` from `lucide-react`, `Agent` from `@paperclipai/shared`, `useState, useRef, useEffect, useCallback` from `react`, `cn` from `../lib/utils`.

**B. Update `ChatInput.tsx`** -- Add Stop button, slash command popover, @mention popover:

New props interface:
```typescript
interface ChatInputProps {
  onSend: (content: string, intent?: MessageIntent) => void;
  onStop?: () => void;
  onClose?: () => void;
  isSubmitting?: boolean;
  streaming?: boolean;
  agents?: Agent[];
  className?: string;
}
```

Changes:
1. Import `parseMessageIntent, SLASH_COMMANDS, type MessageIntent` from `../lib/parseMessageIntent`.
2. Import `Square` from `lucide-react`.
3. Import `Popover, PopoverContent, PopoverTrigger` from `@/components/ui/popover`.
4. Import `Command, CommandItem, CommandList` from `@/components/ui/command`.

5. **Stop button**: When `streaming === true`, replace the Send button with:
   ```tsx
   <Button
     variant="destructive"
     size="icon"
     onClick={onStop}
     aria-label="Stop generation"
     className="h-10 w-10 shrink-0 transition-opacity duration-100"
   >
     <Square className="h-4 w-4" />
   </Button>
   ```
   The textarea should be disabled when `streaming === true`.

6. **handleSend** update: Parse intent before sending:
   ```typescript
   const handleSend = useCallback(() => {
     const trimmed = value.trim();
     if (!trimmed || isSubmitting || streaming) return;
     const intent = parseMessageIntent(trimmed);
     onSend(intent.text || trimmed, intent);
     setValue("");
     if (textareaRef.current) textareaRef.current.style.height = "auto";
   }, [value, isSubmitting, streaming, onSend]);
   ```

7. **Slash command popover**: Track `showSlashPopover` state. In `onChange`:
   - If value starts with `/` and value length >= 2: filter SLASH_COMMANDS entries matching the prefix, show popover if matches > 0
   - Otherwise hide popover
   - Render a `<Popover open={showSlashPopover}>` positioned above the input
   - Each match as `<CommandItem>` with the command label + destination agent name (from UI-SPEC table)
   - On item select: replace input value with the full command + space, close popover

8. **@mention popover**: Track `showMentionPopover` state. In `onChange`:
   - If value starts with `@` and length >= 2: filter agents by name prefix, show popover
   - Render same `<Popover>` + `<Command>` pattern
   - On item select: replace input with `@{agentName} `, close popover

The popover trigger is the textarea container itself (invisible trigger -- use `<PopoverAnchor>` on the textarea wrapper div).

**C. Update `ChatPanel.tsx`** -- Wire everything together:

1. Import `AgentSelector` from `./AgentSelector`.
2. Import `useStreamMessage, useEditMessage` from `../hooks/useChatMessages`.
3. Import `useUpdateConversationAgent` from `../hooks/useChatConversations`.
4. Import `useQuery` from `@tanstack/react-query`.
5. Import `agentsApi` from `../api/agents`.
6. Import `queryKeys` from `../lib/queryKeys`.
7. Import `parseMessageIntent` from `../lib/parseMessageIntent`.
8. Import `Agent` from `@paperclipai/shared`.

9. Add agent fetching:
   ```typescript
   const { data: agents = [], isLoading: agentsLoading } = useQuery({
     queryKey: queryKeys.agents.list(selectedCompanyId!),
     queryFn: () => agentsApi.list(selectedCompanyId!),
     enabled: !!selectedCompanyId,
   });
   ```

10. Add streaming hook:
    ```typescript
    const stream = useStreamMessage(activeConversationId);
    const editMessage = useEditMessage(activeConversationId);
    const updateAgent = useUpdateConversationAgent();
    ```

11. Get current conversation's agentId (use a separate query or derive from conversations list):
    ```typescript
    const { data: activeConversation } = useQuery({
      queryKey: ["chat", "conversation", activeConversationId],
      queryFn: () => chatApi.getConversation(activeConversationId!),
      enabled: !!activeConversationId,
    });
    ```

12. Update `handleSend` to use streaming:
    ```typescript
    const handleSend = useCallback(
      async (content: string, intent?: MessageIntent) => {
        if (!activeConversationId) {
          if (!selectedCompanyId) return;
          try {
            const conversation = await createConversation.mutateAsync(undefined);
            setActiveConversationId(conversation.id);
            // Can't stream yet -- conversation just created, need to wait for state update
            // Queue the send for after state settles
            setTimeout(() => stream.send(content, resolveAgentId(intent, agents, conversation.agentId)), 50);
          } catch { /* ignore */ }
        } else {
          const agentId = resolveAgentIdForIntent(intent, agents, activeConversation?.agentId ?? null);
          stream.send(content, agentId);
        }
      },
      [activeConversationId, selectedCompanyId, createConversation, setActiveConversationId, stream, agents, activeConversation],
    );
    ```

13. Add a helper function in ChatPanel or import from parseMessageIntent:
    ```typescript
    function resolveAgentIdForIntent(
      intent: MessageIntent | undefined,
      agents: Agent[],
      defaultAgentId: string | null,
    ): string | null {
      if (!intent) return defaultAgentId;
      if (intent.targetRole) {
        const match = agents.find(a => a.role === intent.targetRole);
        return match?.id ?? defaultAgentId;
      }
      if (intent.targetName) {
        const match = agents.find(a => a.name.toLowerCase() === intent.targetName);
        return match?.id ?? defaultAgentId;
      }
      return defaultAgentId;
    }
    ```

14. Add `handleAgentSelect`:
    ```typescript
    const handleAgentSelect = useCallback((agentId: string) => {
      if (!activeConversationId) return;
      updateAgent.mutate({ conversationId: activeConversationId, agentId });
    }, [activeConversationId, updateAgent]);
    ```

15. Add `handleRetry` and `handleEditMessage`:
    ```typescript
    const handleRetry = useCallback(() => {
      stream.retry(activeConversation?.agentId ?? null);
    }, [stream, activeConversation]);

    const handleEditMessage = useCallback((messageId: string, content: string) => {
      editMessage.mutate({ messageId, content });
      // After edit, trigger re-generation
      stream.retry(activeConversation?.agentId ?? null);
    }, [editMessage, stream, activeConversation]);
    ```

16. Add `AgentSelector` to the panel header. Modify the inner layout -- add a header bar above the message area:
    ```tsx
    {/* Message area */}
    <div className="flex flex-1 flex-col min-w-0 overflow-hidden">
      {/* Header with agent selector */}
      {activeConversationId && (
        <div className="flex items-center border-b border-border px-3 h-12 shrink-0">
          <AgentSelector
            agents={agents}
            currentAgentId={activeConversation?.agentId ?? null}
            onSelect={handleAgentSelect}
            isLoading={agentsLoading}
          />
        </div>
      )}

      {activeConversationId ? (
        <ChatMessageList
          conversationId={activeConversationId}
          streaming={stream.streaming}
          partialContent={stream.partialContent}
          agents={agents}
          onRetry={handleRetry}
          onEditMessage={handleEditMessage}
        />
      ) : (
        <div className="flex flex-1 items-center justify-center p-4 text-center">
          <p className="text-sm text-muted-foreground">Select a conversation or start a new one.</p>
        </div>
      )}

      <ChatInput
        onSend={handleSend}
        onStop={stream.stop}
        onClose={handleClose}
        isSubmitting={sendMessage.isPending || createConversation.isPending}
        streaming={stream.streaming}
        agents={agents}
      />
    </div>
    ```

17. Remove the old `sendMessage` useSendMessage hook usage since streaming now handles sending. Keep the import for `useSendMessage` only if still needed for non-streaming fallback, otherwise remove.
pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run - grep -q "VList" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "virtua" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "ChatAgentBadge" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "isAtBottom" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "Jump to bottom" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "Pencil" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "RotateCcw" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "animate-pulse" ui/src/components/ChatMessageList.tsx returns 0 - grep -q "Square" ui/src/components/ChatInput.tsx returns 0 - grep -q "Stop generation" ui/src/components/ChatInput.tsx returns 0 - grep -q "parseMessageIntent" ui/src/components/ChatInput.tsx returns 0 - grep -q "SLASH_COMMANDS" ui/src/components/ChatInput.tsx returns 0 - grep -q "AgentSelector" ui/src/components/ChatPanel.tsx returns 0 - grep -q "useStreamMessage" ui/src/components/ChatPanel.tsx returns 0 - grep -q "useEditMessage" ui/src/components/ChatPanel.tsx returns 0 - grep -q "useUpdateConversationAgent" ui/src/components/ChatPanel.tsx returns 0 - grep -q "resolveAgentIdForIntent" ui/src/components/ChatPanel.tsx returns 0 - pnpm --filter @paperclipai/ui build exits 0 - pnpm --filter @paperclipai/ui test run exits 0 ChatMessageList uses VList with agent badges, edit/retry buttons, streaming indicator, and jump-to-bottom. ChatInput has Stop button, slash command popover, and @mention popover. ChatPanel integrates AgentSelector, streaming, edit, retry, and agent resolution. Build and all tests pass. - `pnpm --filter @paperclipai/ui build` -- TypeScript compiles - `pnpm --filter @paperclipai/ui test run` -- all UI tests pass - `pnpm test run` -- full suite green - ChatMessageList uses VList from virtua - ChatInput shows Stop button during streaming - ChatPanel has AgentSelector in header - Slash commands and @mentions are parsed and routed

<success_criteria>

  1. Streaming tokens appear in a live assistant message bubble via EventSource
  2. Stop button (Square icon, destructive variant) replaces Send during streaming
  3. Retry button (RotateCcw) appears on hover over assistant messages
  4. Edit button (Pencil) appears on hover over user messages with inline textarea
  5. AgentSelector in ChatPanel header shows all agents and persists selection via PATCH
  6. VList virtualizes the message list for smooth scrolling with 1000+ messages
  7. Slash commands populate a popover and route to correct agent role
  8. @mention popover shows filtered agents and routes to named agent
  9. Jump to bottom button appears when user scrolls up
  10. All tests pass and build succeeds </success_criteria>
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`