feat(22-05): virtualize ChatMessageList and add chat API edit/truncate methods

- Rewrite ChatMessageList with @tanstack/react-virtual useVirtualizer (estimateSize: 80, overscan: 5)
- Dynamic height measurement via measureElement ref
- Streaming message appended as synthetic __streaming__ entry
- virtualizer.measure() called on streamingContent change (Pitfall 3)
- Jump-to-bottom button when scrolled >200px from bottom
- 3 Skeleton loading placeholders
- Add editMessage, truncateMessagesAfter, deleteMessage to chatApi
- Add postMessageAndStream and savePartialMessage (were missing from prior plan)
- Fix duplicate ChatConversation type exports in packages/shared/src/index.ts (Rule 1 - Bug)
This commit is contained in:
Nexus Dev 2026-04-01 18:33:37 +00:00
parent 724e2b2bd8
commit 954e7819c6
4 changed files with 249 additions and 22 deletions

View file

@ -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";

View file

@ -54,4 +54,98 @@ export const chatApi = {
) {
return api.post<ChatMessage>(`/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<void> {
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<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
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",
});
},
async deleteMessage(conversationId: string, messageId: string) {
await fetch(`/api/conversations/${conversationId}/messages/${messageId}`, {
method: "DELETE",
credentials: "include",
});
},
};

View file

@ -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");
});

View file

@ -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<string, { name: string; icon: string | null; role: AgentRole | null }>;
}
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<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(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<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(() => {
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 (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Loading messages...</p>
<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 (messages.length === 0) {
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>
@ -32,11 +107,70 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
}
return (
<div className="space-y-4 p-3">
{messages.map((message) => (
<ChatMessage key={message.id} role={message.role} content={message.content} />
))}
<div ref={bottomRef} />
<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>
);
}