` with:
```tsx
{allMessages.map((msg) => (
))}
{streaming && partialContent && (
)}
{!isAtBottom && (
{
listRef.current?.scrollToIndex(allMessages.length - 1, { smooth: false });
setIsAtBottom(true);
}}
>
)}
```
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 (
{/* Agent badge for assistant messages */}
{message.role === "assistant" && (
)}
{/* Message bubble */}
{editing ? (
) : message.role === "user" ? (
{message.editedContent ?? message.content}
) : (
)}
{/* Timestamp */}
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{message.editedAt && " (edited)"}
{/* Action buttons -- visible on hover, hidden during streaming */}
{!streaming && !editing && (
{message.role === "user" && (
setEditing(true)}>
)}
{message.role === "assistant" && (
)}
)}
);
}
```
**StreamingMessage** (inline):
```tsx
function StreamingMessage({ content, agents }: { content: string; agents: Agent[] }) {
return (
);
}
```
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
```
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 `
` positioned above the input
- Each match as `` 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 `` + `` pattern
- On item select: replace input with `@{agentName} `, close popover
The popover trigger is the textarea container itself (invisible trigger -- use `` 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 */}
{/* Header with agent selector */}
{activeConversationId && (
)}
{activeConversationId ? (
) : (
Select a conversation or start a new one.
)}
```
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
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
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`