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:
@@ -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 { List, type ListImperativeAPI } from 'react-window';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
@@ -9,6 +10,23 @@ import { Button, EmptyState } from './ui';
|
||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||
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() {
|
||||
const {
|
||||
@@ -26,6 +44,27 @@ export function ChatArea() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
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
|
||||
const currentClone = useMemo(() => {
|
||||
if (!currentAgent) return null;
|
||||
@@ -58,10 +97,12 @@ export function ChatArea() {
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
if (scrollRef.current && !useVirtualization) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
} else if (useVirtualization && messages.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, useVirtualization, scrollToBottom]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isStreaming || !connected) return;
|
||||
@@ -155,7 +196,17 @@ export function ChatArea() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
{/* Virtualized list for large message counts, smooth scroll for small counts */}
|
||||
{useVirtualization && messages.length > 0 ? (
|
||||
<VirtualizedMessageList
|
||||
messages={messages}
|
||||
listRef={listRef}
|
||||
getHeight={getHeight}
|
||||
onHeightChange={setHeight}
|
||||
messageRefs={messageRefs}
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
|
||||
@@ -167,7 +218,8 @@ export function ChatArea() {
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
</motion.div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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 */
|
||||
|
||||
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[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
@@ -418,9 +484,9 @@ function renderInline(text: string): React.ReactNode[] {
|
||||
</code>
|
||||
);
|
||||
} else if (match[7]) {
|
||||
// [text](url)
|
||||
// [text](url) - 使用 sanitizeUrl 防止 XSS
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -519,3 +585,110 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user