875 lines
33 KiB
Markdown
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>
|