811 lines
34 KiB
Markdown
811 lines
34 KiB
Markdown
---
|
|
phase: 22-agent-streaming
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [22-01, 22-02]
|
|
files_modified:
|
|
- ui/src/api/chat.ts
|
|
- ui/src/hooks/useChatMessages.ts
|
|
- ui/src/hooks/useChatConversations.ts
|
|
- ui/src/components/ChatMessageList.tsx
|
|
- ui/src/components/ChatInput.tsx
|
|
- ui/src/components/ChatPanel.tsx
|
|
autonomous: true
|
|
requirements: [CHAT-01, CHAT-11, CHAT-12, PERF-02, PERF-03, INPUT-05, INPUT-06, AGENT-04, CHAT-08, CHAT-10]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User sends a message and sees tokens stream in real-time as an assistant message bubble"
|
|
- "User can click Stop to cancel an in-progress stream"
|
|
- "User can click Retry on any assistant message to regenerate the response"
|
|
- "User can edit a previous user message and trigger regeneration"
|
|
- "Agent selector in chat panel header switches the active agent for the conversation"
|
|
- "Agent badge shows above each assistant message with colored avatar and name"
|
|
- "Slash commands route messages to the correct agent for that single message"
|
|
- "@mention routes to the named agent for that single message"
|
|
- "1000+ messages render without jank using virtua VList"
|
|
- "Slash command popover appears when typing / in the input"
|
|
artifacts:
|
|
- path: "ui/src/hooks/useChatMessages.ts"
|
|
provides: "useStreamMessage hook with streaming state, partialContent, stop/send/retry/edit"
|
|
exports: ["useStreamMessage", "useEditMessage"]
|
|
- path: "ui/src/components/ChatMessageList.tsx"
|
|
provides: "Virtualized message list with VList, agent badges, edit/retry buttons, streaming indicator"
|
|
contains: "VList"
|
|
- path: "ui/src/components/ChatInput.tsx"
|
|
provides: "Stop button during streaming, slash command popover, @mention popover"
|
|
contains: "Square"
|
|
- path: "ui/src/components/ChatPanel.tsx"
|
|
provides: "AgentSelector in header, streaming state threading"
|
|
contains: "AgentSelector"
|
|
key_links:
|
|
- from: "ui/src/hooks/useChatMessages.ts"
|
|
to: "ui/src/api/chat.ts"
|
|
via: "chatApi.sendMessage + EventSource for streaming"
|
|
pattern: "EventSource|chatApi"
|
|
- from: "ui/src/components/ChatMessageList.tsx"
|
|
to: "ui/src/components/ChatAgentBadge.tsx"
|
|
via: "import { ChatAgentBadge }"
|
|
pattern: "ChatAgentBadge"
|
|
- from: "ui/src/components/ChatInput.tsx"
|
|
to: "ui/src/lib/parseMessageIntent.ts"
|
|
via: "import { parseMessageIntent, SLASH_COMMANDS }"
|
|
pattern: "parseMessageIntent"
|
|
- from: "ui/src/components/ChatPanel.tsx"
|
|
to: "ui/src/components/AgentSelector.tsx"
|
|
via: "import { AgentSelector }"
|
|
pattern: "AgentSelector"
|
|
---
|
|
|
|
<objective>
|
|
Wire all Phase 22 pieces together: streaming hook with EventSource, virtualized ChatMessageList with agent badges and action buttons, ChatInput with stop/popover/parsing, and ChatPanel integration with AgentSelector.
|
|
|
|
Purpose: This is the integration plan that connects the server SSE endpoint (Plan 01) with the UI components (Plan 02) into a working streaming chat experience.
|
|
Output: Complete streaming chat with agent selection, edit/retry, stop generation, slash commands, @mentions, and virtualized scrolling.
|
|
</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
|
|
|
|
<interfaces>
|
|
<!-- From Plan 01 (server) -->
|
|
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:
|
|
```typescript
|
|
interface ChatMessage {
|
|
id: string;
|
|
conversationId: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
agentId: string | null;
|
|
editedContent: string | null;
|
|
editedAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
```
|
|
|
|
<!-- From Plan 02 (UI components) -->
|
|
```typescript
|
|
// 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;
|
|
```
|
|
|
|
<!-- Existing from Phase 21 -->
|
|
From ui/src/api/chat.ts:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
export function useChatMessages(conversationId: string | null); // useInfiniteQuery
|
|
export function useSendMessage(conversationId: string | null); // useMutation
|
|
```
|
|
|
|
From ui/src/hooks/useChatConversations.ts:
|
|
```typescript
|
|
export function useConversationActions(); // returns pin/unpin/archive/remove/rename mutations
|
|
```
|
|
|
|
From ui/src/context/ChatPanelContext.tsx:
|
|
```typescript
|
|
export function useChatPanel(); // { chatOpen, setChatOpen, activeConversationId, setActiveConversationId }
|
|
```
|
|
|
|
From ui/src/lib/queryKeys.ts:
|
|
```typescript
|
|
agents: { list: (companyId: string) => ["agents", companyId] as const }
|
|
```
|
|
|
|
virtua API:
|
|
```typescript
|
|
import { VList } from "virtua";
|
|
// <VList ref={ref} style={{ flex: 1 }}>{children}</VList>
|
|
// ref.current.scrollToIndex(index, { smooth: false })
|
|
// onScroll callback provides scrollOffset, scrollSize, viewportSize
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install virtua + API client additions + useStreamMessage hook + useEditMessage hook + useUpdateConversationAgent</name>
|
|
<files>
|
|
ui/package.json,
|
|
ui/src/api/chat.ts,
|
|
ui/src/hooks/useChatMessages.ts,
|
|
ui/src/hooks/useChatConversations.ts
|
|
</files>
|
|
<read_first>
|
|
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)
|
|
</read_first>
|
|
<action>
|
|
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";`).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: ChatMessageList virtualization + agent badges + action buttons + ChatInput streaming/popover + ChatPanel AgentSelector integration</name>
|
|
<files>
|
|
ui/src/components/ChatMessageList.tsx,
|
|
ui/src/components/ChatInput.tsx,
|
|
ui/src/components/ChatPanel.tsx
|
|
</files>
|
|
<read_first>
|
|
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)
|
|
</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui test run</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
1. Streaming tokens appear in a live assistant message bubble via EventSource
|
|
2. Stop button (Square icon, destructive variant) replaces Send during streaming
|
|
3. Retry button (RotateCcw) appears on hover over assistant messages
|
|
4. Edit button (Pencil) appears on hover over user messages with inline textarea
|
|
5. AgentSelector in ChatPanel header shows all agents and persists selection via PATCH
|
|
6. VList virtualizes the message list for smooth scrolling with 1000+ messages
|
|
7. Slash commands populate a popover and route to correct agent role
|
|
8. @mention popover shows filtered agents and routes to named agent
|
|
9. Jump to bottom button appears when user scrolls up
|
|
10. All tests pass and build succeeds
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`
|
|
</output>
|