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:
parent
724e2b2bd8
commit
954e7819c6
4 changed files with 249 additions and 22 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue