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

875 lines
33 KiB
Markdown

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