33 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22-agent-streaming | 05 | execute | 3 |
|
|
false |
|
|
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.
<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 @.planning/phases/22-agent-streaming/22-03-SUMMARY.md @.planning/phases/22-agent-streaming/22-04-SUMMARY.md 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):
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):
export function ChatStopButton({ onStop }: { onStop: () => void }): JSX.Element;
From ui/src/components/ChatMessage.tsx (Plan 03):
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):
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):
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):
export function resolveAgentFromContent(
content: string,
agents: Array<{ id: string; name: string; role: string }>,
activeAgentId: string | null,
): string | null;
From ui/src/hooks/useChatMessages.ts:
export function useChatMessages(conversationId: string | null): {
messages: ChatMessage[]; isLoading: boolean; sendMutation: UseMutationResult;
// ... infinite query props
};
From ui/src/api/chat.ts:
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
Task 1: Virtualized ChatMessageList and chat API edit/truncate methods
- 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)
ui/src/components/ChatMessageList.tsx,
ui/src/api/chat.ts,
ui/src/components/ChatMessageList.test.tsx
**1. Add edit and truncate methods to `chatApi` in `ui/src/api/chat.ts`:**
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):
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:
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:
useVirtualizerwithestimateSize: 80,overscan: 5, dynamic measurement viameasureElement- Streaming message appended as synthetic entry with
id: "__streaming__"andisStreamingEntry: true virtualizer.measure()called onstreamingContentchange 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
agentMapor streaming agent props
3. Update test stubs in ui/src/components/ChatMessageList.test.tsx:
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");
});
pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20
- 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
- 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
Task 2: Wire ChatPanel and ChatInput with all Phase 22 features
- 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
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatInput.tsx
**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:
interface ChatInputProps {
onSend: (content: string) => void;
isSubmitting?: boolean;
disabled?: boolean;
placeholder?: string;
// Popover support
agents?: Agent[];
agentsLoading?: boolean;
}
c) Add state for popovers:
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 @:
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:
function handleSlashSelect(command: string) {
setValue(command + " ");
setSlashOpen(false);
textareaRef.current?.focus();
}
f) Handle mention selection:
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:
ChatSlashCommandPopoverwraps the textarea as triggerChatMentionPopoverwraps 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:
useStreamingChathook for streamingChatAgentSelectorin headerChatStopButtonabove input when streaming- Agent resolution from slash commands / @mentions
- Edit and retry handlers
- Agent data for identity bars
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:
useStreamingChatprovides streamingContent, isStreaming, startStream, stopChatAgentSelectorin header withonAgentChangeupdating local stateChatStopButtonshown conditionally whenisStreamingChatInputreceivesagentsfor mention popover,disabledduring streaming, custom placeholderChatMessageListreceives streaming props and agentMap for identity barshandleEditcalls editMessage + truncateMessagesAfter + startStream with edited contenthandleRetryfinds 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 messageresolveAgentFromContentdetermines which agent receives the messageScrollAreareplaced by virtualizer's own scroll container in ChatMessageList pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 <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>
- 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
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)
Type "approved" or describe issues
Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above, testing each of the 9 requirements individually.
pnpm --filter @paperclipai/ui vitest run --reporter=verbose
All 12 verification steps pass visual/functional inspection, with each of the 9 requirements individually confirmed
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
- All components wired and rendering
<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>