diff --git a/web/package-lock.json b/web/package-lock.json index adf73bd..e3f9b7e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "picobot-web", "version": "1.0.0", "dependencies": { + "@tanstack/react-virtual": "^3.14.9", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "lucide-react": "^1.16.0", @@ -1195,6 +1196,33 @@ "tailwindcss": "4.3.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/web/package.json b/web/package.json index 093881b..764b69f 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"" }, "dependencies": { + "@tanstack/react-virtual": "^3.14.9", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "lucide-react": "^1.16.0", diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 2b1d4ed..fa4317f 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { MessageBubble } from './MessageBubble'; import type { ChatMessage } from '../../types/protocol'; import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'; @@ -20,7 +21,6 @@ export function MessageList({ viewKey, highlightedMessageId, }: MessageListProps) { - const bottomRef = useRef(null); const containerRef = useRef(null); const isAtBottomRef = useRef(true); const prevShowBottomRef = useRef(false); @@ -39,14 +39,35 @@ export function MessageList({ const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); + // 虚拟化列表:只渲染视口内的消息,长对话不卡顿。 + // 动态测量元素高度(消息内容长度差异大),overscan 保证滚动平滑。 + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => containerRef.current, + estimateSize: () => 120, + overscan: 6, + measureElement: + typeof window !== 'undefined' && navigator.userAgent.includes('Firefox') + ? (el) => el.getBoundingClientRect().height + : undefined, + }); + + // 消息 id → virtualizer index 映射,用于 highlight 滚动定位 + const messageIdToIndex = useRef>(new Map()); + // ---- scroll helpers ---- - const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { - isAtBottomRef.current = true; - setShowScrollToBottom(false); - setNewMessageCount(0); - bottomRef.current?.scrollIntoView({ behavior }); - }, []); + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + isAtBottomRef.current = true; + setShowScrollToBottom(false); + setNewMessageCount(0); + if (messages.length > 0) { + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior }); + } + }, + [virtualizer, messages.length], + ); const scrollToTop = useCallback(() => { containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); @@ -109,7 +130,7 @@ export function MessageList({ } // First time viewing this view: scroll to bottom isAtBottomRef.current = true; - bottomRef.current?.scrollIntoView({ behavior: 'instant' }); + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); return; } @@ -119,7 +140,7 @@ export function MessageList({ prevMessageCountRef.current = messages.length; if (lastMessage.role === 'user' || isAtBottomRef.current) { - bottomRef.current?.scrollIntoView({ behavior: 'instant' }); + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); // 用户自己发消息或已在底部时,不需要计数 if (newCount > 0) { setNewMessageCount(0); @@ -128,7 +149,7 @@ export function MessageList({ // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) setNewMessageCount((prev) => prev + newCount); } - }, [messages, viewKey]); + }, [messages, viewKey, virtualizer]); // ---- mount: always scroll to bottom if messages already loaded ---- @@ -143,19 +164,23 @@ export function MessageList({ useEffect(() => { if (!highlightedMessageId) return; + const idx = messageIdToIndex.current.get(highlightedMessageId); + if (idx === undefined) return; - const container = containerRef.current; - if (!container) return; + virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); - const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`); - if (!targetElement) return; - - targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - targetElement.classList.add('todo-highlight'); - setTimeout(() => { - targetElement.classList.remove('todo-highlight'); - }, 2000); - }, [highlightedMessageId]); + // 高亮 class 需等 DOM 渲染后操作 + requestAnimationFrame(() => { + const container = containerRef.current; + if (!container) return; + const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`); + if (!targetElement) return; + targetElement.classList.add('todo-highlight'); + setTimeout(() => { + targetElement.classList.remove('todo-highlight'); + }, 2000); + }); + }, [highlightedMessageId, virtualizer]); // ---- empty state ---- @@ -181,24 +206,46 @@ export function MessageList({ ); } + // 构建消息 id → index 映射(每次渲染更新,供 highlight 查找) + messageIdToIndex.current.clear(); + messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i)); + // ---- main render ---- + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + return (
-
- {messages.map((message) => ( - - ))} -
+
+ {/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */} +
+ {virtualItems.map((vi) => { + const message = messages[vi.index]; + return ( +
+ +
+ ); + })} +
{/* 浮动导航按钮 — 底部居中并排 */}