From a65b3d39581cf3b4d7ed4daffdec032db0b3a1b2 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 21 Mar 2026 20:43:07 +0800 Subject: [PATCH] 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 --- desktop/src/components/ChatArea.tsx | 209 +++++++++++++++++++++++++--- 1 file changed, 191 insertions(+), 18 deletions(-) diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index 0a1eec0..d750cee 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -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 = { + 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(null); const messageRefs = useRef>(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,19 +196,30 @@ export function ChatArea() { )} - {messages.map((message) => ( - { if (el) messageRefs.current.set(message.id, el); }} - variants={listItemVariants} - initial="hidden" - animate="visible" - layout - transition={defaultTransition} - > - - - ))} + {/* Virtualized list for large message counts, smooth scroll for small counts */} + {useVirtualization && messages.length > 0 ? ( + + ) : ( + messages.map((message) => ( + { if (el) messageRefs.current.set(message.id, el); }} + variants={listItemVariants} + initial="hidden" + animate="visible" + layout + transition={defaultTransition} + > + + + )) + )} @@ -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[] { ); } else if (match[7]) { - // [text](url) + // [text](url) - 使用 sanitizeUrl 防止 XSS parts.push( - {match[8]} ); } @@ -519,3 +585,110 @@ function MessageBubble({ message }: { message: Message }) { ); } + +// === Virtualized Message Components === + +interface VirtualizedMessageRowProps { + message: Message; + onHeightChange: (height: number) => void; + messageRefs: MutableRefObject>; +} + +/** + * 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(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 ( +
{ + if (el) { + (rowRef as MutableRefObject).current = el; + messageRefs.current.set(message.id, el); + } + }} + style={style} + className="py-3" + {...ariaAttributes} + > + +
+ ); +} + +interface VirtualizedMessageListProps { + messages: Message[]; + listRef: RefObject; + getHeight: (id: string, role: string) => number; + onHeightChange: (id: string, height: number) => void; + messageRefs: MutableRefObject>; +} + +/** + * 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; + }) => ( + onHeightChange(messages[props.index].id, h)} + messageRefs={messageRefs} + style={props.style} + ariaAttributes={props.ariaAttributes} + /> + ); + + return ( + getHeight(messages[index].id, messages[index].role)} + rowCount={messages.length} + defaultHeight={500} + overscanCount={5} + className="focus:outline-none" + /> + ); +}