34 KiB
34 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22-agent-streaming | 03 | execute | 2 |
|
|
true |
|
|
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>
- Streaming tokens appear in a live assistant message bubble via EventSource
- Stop button (Square icon, destructive variant) replaces Send during streaming
- Retry button (RotateCcw) appears on hover over assistant messages
- Edit button (Pencil) appears on hover over user messages with inline textarea
- AgentSelector in ChatPanel header shows all agents and persists selection via PATCH
- VList virtualizes the message list for smooth scrolling with 1000+ messages
- Slash commands populate a popover and route to correct agent role
- @mention popover shows filtered agents and routes to named agent
- Jump to bottom button appears when user scrolls up
- All tests pass and build succeeds </success_criteria>