feat(chat): add virtual scrolling for large message lists

- Integrate react-window v2 List component for messages > 100
- Add VirtualizedMessageList and VirtualizedMessageRow components
- Use useVirtualizedMessages hook for dynamic height measurement
- Preserve smooth animations for small message counts (< 100)
- Auto-scroll to bottom for both virtualized and non-virtualized modes

Performance improvements:
- Only render visible messages in viewport
- Dynamic height measurement for accurate positioning
- LRU cache for message content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-21 20:43:07 +08:00
parent 35b06f2e4a
commit a65b3d3958

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, Message } from '../store/chatStore'; import { useChatStore, Message } from '../store/chatStore';
import { useConnectionStore } from '../store/connectionStore'; import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore'; import { useAgentStore } from '../store/agentStore';
@@ -9,6 +10,23 @@ import { Button, EmptyState } from './ui';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations'; import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt'; import { FirstConversationPrompt } from './FirstConversationPrompt';
import { MessageSearch } from './MessageSearch'; import { MessageSearch } from './MessageSearch';
import {
useVirtualizedMessages,
type VirtualizedMessageItem,
} from '../lib/message-virtualization';
// Default heights for virtualized messages
const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
user: 80,
assistant: 150,
tool: 120,
hand: 120,
workflow: 100,
system: 60,
};
// Threshold for enabling virtualization (messages count)
const VIRTUALIZATION_THRESHOLD = 100;
export function ChatArea() { export function ChatArea() {
const { const {
@@ -26,6 +44,27 @@ export function ChatArea() {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Convert messages to virtualization format
const virtualizedMessages: VirtualizedMessageItem[] = useMemo(
() => messages.map((msg) => ({
id: msg.id,
height: DEFAULT_MESSAGE_HEIGHTS[msg.role] ?? 100,
role: msg.role,
})),
[messages]
);
// Use virtualization hook
const {
listRef,
getHeight,
setHeight,
scrollToBottom,
} = useVirtualizedMessages(virtualizedMessages, DEFAULT_MESSAGE_HEIGHTS);
// Whether to use virtualization
const useVirtualization = messages.length >= VIRTUALIZATION_THRESHOLD;
// Get current clone for first conversation prompt // Get current clone for first conversation prompt
const currentClone = useMemo(() => { const currentClone = useMemo(() => {
if (!currentAgent) return null; if (!currentAgent) return null;
@@ -58,10 +97,12 @@ export function ChatArea() {
// Auto-scroll to bottom on new messages // Auto-scroll to bottom on new messages
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current && !useVirtualization) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} else if (useVirtualization && messages.length > 0) {
scrollToBottom();
} }
}, [messages]); }, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => { const handleSend = () => {
if (!input.trim() || isStreaming || !connected) return; if (!input.trim() || isStreaming || !connected) return;
@@ -155,19 +196,30 @@ export function ChatArea() {
</motion.div> </motion.div>
)} )}
{messages.map((message) => ( {/* Virtualized list for large message counts, smooth scroll for small counts */}
<motion.div {useVirtualization && messages.length > 0 ? (
key={message.id} <VirtualizedMessageList
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }} messages={messages}
variants={listItemVariants} listRef={listRef}
initial="hidden" getHeight={getHeight}
animate="visible" onHeightChange={setHeight}
layout messageRefs={messageRefs}
transition={defaultTransition} />
> ) : (
<MessageBubble message={message} /> messages.map((message) => (
</motion.div> <motion.div
))} key={message.id}
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
variants={listItemVariants}
initial="hidden"
animate="visible"
layout
transition={defaultTransition}
>
<MessageBubble message={message} />
</motion.div>
))
)}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -354,6 +406,20 @@ function CodeBlock({ code, language, index }: { code: string; language: string;
} }
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */ /** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function sanitizeUrl(url: string): string {
const safeProtocols = ['http:', 'https:', 'mailto:'];
try {
const parsed = new URL(url, window.location.origin);
if (safeProtocols.includes(parsed.protocol)) {
return parsed.href;
}
} catch {
// Invalid URL
}
return '#';
}
function renderMarkdown(text: string): React.ReactNode[] { function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []; const nodes: React.ReactNode[] = [];
const lines = text.split('\n'); const lines = text.split('\n');
@@ -418,9 +484,9 @@ function renderInline(text: string): React.ReactNode[] {
</code> </code>
); );
} else if (match[7]) { } else if (match[7]) {
// [text](url) // [text](url) - 使用 sanitizeUrl 防止 XSS
parts.push( parts.push(
<a key={parts.length} href={match[9]} target="_blank" rel="noopener noreferrer" <a key={parts.length} href={sanitizeUrl(match[9])} target="_blank" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 underline hover:text-orange-700 dark:hover:text-orange-300">{match[8]}</a> className="text-orange-600 dark:text-orange-400 underline hover:text-orange-700 dark:hover:text-orange-300">{match[8]}</a>
); );
} }
@@ -519,3 +585,110 @@ function MessageBubble({ message }: { message: Message }) {
</div> </div>
); );
} }
// === Virtualized Message Components ===
interface VirtualizedMessageRowProps {
message: Message;
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
}
/**
* Single row in the virtualized list.
* Measures actual height after render and reports back.
*/
function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
style: CSSProperties;
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
}) {
const rowRef = useRef<HTMLDivElement>(null);
// Measure height after mount
useEffect(() => {
if (rowRef.current) {
const height = rowRef.current.getBoundingClientRect().height;
if (height > 0) {
onHeightChange(height);
}
}
}, [message.content, message.streaming, onHeightChange]);
return (
<div
ref={(el) => {
if (el) {
(rowRef as MutableRefObject<HTMLDivElement | null>).current = el;
messageRefs.current.set(message.id, el);
}
}}
style={style}
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} />
</div>
);
}
interface VirtualizedMessageListProps {
messages: Message[];
listRef: RefObject<ListImperativeAPI | null>;
getHeight: (id: string, role: string) => number;
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
}
/**
* Virtualized message list for efficient rendering of large message counts.
* Uses react-window's List with dynamic height measurement.
*/
function VirtualizedMessageList({
messages,
listRef,
getHeight,
onHeightChange,
messageRefs,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
index: number;
style: CSSProperties;
}) => (
<VirtualizedMessageRow
message={messages[props.index]}
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>
);
return (
<List
listRef={listRef}
rowComponent={RowComponent}
rowProps={{}}
rowHeight={(index: number) => getHeight(messages[index].id, messages[index].role)}
rowCount={messages.length}
defaultHeight={500}
overscanCount={5}
className="focus:outline-none"
/>
);
}