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 { 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user