diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 29462e58..447737e5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -660,10 +660,3 @@ export { type ConfigMeta, } from "./config-schema.js"; -export { - type ChatConversation, - type ChatConversationListItem, - type ChatMessage, - type ChatConversationListResponse, - type ChatMessageListResponse, -} from "./types/chat.js"; diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index c05150d2..24b195ef 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -54,4 +54,98 @@ export const chatApi = { ) { return api.post(`/conversations/${conversationId}/messages`, data); }, + + async postMessageAndStream( + conversationId: string, + data: { content: string; agentId?: string }, + callbacks: { + onToken: (token: string) => void; + onDone: (messageId: string, content: string) => void; + onError: (error: string) => void; + }, + signal?: AbortSignal, + ): Promise { + const response = await fetch(`/api/conversations/${conversationId}/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data), + signal, + }); + + if (!response.ok) { + callbacks.onError(`HTTP ${response.status}`); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + callbacks.onError("No response body"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const raw = line.slice(6).trim(); + if (raw === "[DONE]") continue; + try { + const parsed = JSON.parse(raw) as { type: string; token?: string; messageId?: string; content?: string; error?: string }; + if (parsed.type === "token" && parsed.token !== undefined) { + callbacks.onToken(parsed.token); + } else if (parsed.type === "done" && parsed.messageId !== undefined) { + callbacks.onDone(parsed.messageId, parsed.content ?? ""); + } else if (parsed.type === "error") { + callbacks.onError(parsed.error ?? "Unknown error"); + } + } catch { + // ignore malformed SSE lines + } + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + callbacks.onError((err as Error).message); + } + } finally { + reader.releaseLock(); + } + }, + + savePartialMessage( + conversationId: string, + data: { role: string; content: string }, + ) { + return api.post(`/conversations/${conversationId}/messages`, data); + }, + + async editMessage(conversationId: string, messageId: string, content: string) { + return api.patch(`/conversations/${conversationId}/messages/${messageId}`, { content }); + }, + + async truncateMessagesAfter(conversationId: string, messageId: string) { + await fetch(`/api/conversations/${conversationId}/messages/after/${messageId}`, { + method: "DELETE", + credentials: "include", + }); + }, + + async deleteMessage(conversationId: string, messageId: string) { + await fetch(`/api/conversations/${conversationId}/messages/${messageId}`, { + method: "DELETE", + credentials: "include", + }); + }, }; diff --git a/ui/src/components/ChatMessageList.test.tsx b/ui/src/components/ChatMessageList.test.tsx index 71ad87ff..e024a691 100644 --- a/ui/src/components/ChatMessageList.test.tsx +++ b/ui/src/components/ChatMessageList.test.tsx @@ -1,9 +1,15 @@ -import { describe, it } from "vitest"; +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"); }); diff --git a/ui/src/components/ChatMessageList.tsx b/ui/src/components/ChatMessageList.tsx index 6ffa9708..5ada7423 100644 --- a/ui/src/components/ChatMessageList.tsx +++ b/ui/src/components/ChatMessageList.tsx @@ -1,29 +1,104 @@ -import { useEffect, useRef } from "react"; +import { useRef, useEffect, useCallback, useState } 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"; 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; } -export function ChatMessageList({ conversationId }: ChatMessageListProps) { +export function ChatMessageList({ + conversationId, + streamingContent, + isStreaming, + streamingAgentName, + streamingAgentIcon, + streamingAgentRole, + onEdit, + onRetry, + agentMap, +}: ChatMessageListProps) { const { messages, isLoading } = useChatMessages(conversationId); - const bottomRef = useRef(null); + const parentRef = useRef(null); + const [showJumpToBottom, setShowJumpToBottom] = useState(false); - // Auto-scroll to bottom when new messages arrive + // Build display list: real messages + optional synthetic streaming message + const displayMessages: Array = [ + ...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(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages.length]); + if (displayMessages.length > 0 && !showJumpToBottom) { + virtualizer.scrollToIndex(displayMessages.length - 1, { align: "end" }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayMessages.length]); + + // Re-measure streaming message as it grows (Pitfall 3 from RESEARCH.md) + useEffect(() => { + if (isStreaming && displayMessages.length > 0) { + virtualizer.measure(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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 ( -
-

Loading messages...

+
+ + +
); } - if (messages.length === 0) { + if (displayMessages.length === 0) { return (

Send a message to start this conversation.

@@ -32,11 +107,70 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { } return ( -
- {messages.map((message) => ( - - ))} -
+
+
+
+ {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 ( +
+ +
+ ); + })} +
+
+ + {/* Jump to bottom button */} + {showJumpToBottom && ( +
+ +
+ )}
); }