import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } 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'; interface MessageListProps { messages: ChatMessage[]; onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void; showThinking?: boolean; /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ viewKey?: string; /** 高亮的消息 ID,点击待办项后滚动并高亮显示 */ highlightedMessageId?: string | null; /** 主代理当前生效模型(透传给 MessageBubble 做 Task 卡片差异显示) */ effectiveModel?: { provider: string; model: string } | null; } export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId, effectiveModel, }: MessageListProps) { const containerRef = useRef(null); const isAtBottomRef = useRef(true); const prevShowBottomRef = useRef(false); const prevViewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey); viewKeyRef.current = viewKey; // 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。 // 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变, // 不应计入 newMessageCount。 const prevMessageCountRef = useRef(messages.length); // Per-view scroll position memory const scrollPositionsRef = useRef>(new Map()); 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 滚动定位。 // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。 const messageIdToIndex = useMemo(() => { const map = new Map(); messages.forEach((m, i) => map.set(m.id, i)); return map; }, [messages]); // ---- scroll helpers ---- 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' }); }, []); // ---- scroll event: track whether user is at bottom ---- const handleScroll = useCallback(() => { const el = containerRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const nearBottom = distanceFromBottom < 120; // Save scroll position for current view const key = viewKeyRef.current; if (key) { scrollPositionsRef.current.set(key, el.scrollTop); } isAtBottomRef.current = nearBottom; // 回到底部:距底部 > 200px 时显示(同时显示回到顶部) const shouldShowBottom = distanceFromBottom > 200; if (shouldShowBottom !== prevShowBottomRef.current) { prevShowBottomRef.current = shouldShowBottom; setShowScrollToBottom(shouldShowBottom); } // 滚回底部时清除新消息计数 if (nearBottom) { setNewMessageCount(0); } }, []); // ---- auto-scroll: handle view switches and message updates ---- useLayoutEffect(() => { const prevKey = prevViewKeyRef.current; const viewChanged = prevKey !== viewKey; prevViewKeyRef.current = viewKey; if (messages.length === 0) { isAtBottomRef.current = true; prevMessageCountRef.current = 0; return; } if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position prevMessageCountRef.current = messages.length; const key = viewKey ?? ''; const savedPos = scrollPositionsRef.current.get(key); if (savedPos !== undefined && containerRef.current) { containerRef.current.scrollTop = savedPos; const el = containerRef.current; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; isAtBottomRef.current = distanceFromBottom < 120; return; } // First time viewing this view: scroll to bottom isAtBottomRef.current = true; virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); return; } // Same view, messages changed: normal auto-scroll logic const lastMessage = messages[messages.length - 1]; const newCount = messages.length - prevMessageCountRef.current; prevMessageCountRef.current = messages.length; if (lastMessage.role === 'user' || isAtBottomRef.current) { virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); // 用户自己发消息或已在底部时,不需要计数 if (newCount > 0) { setNewMessageCount(0); } } else if (newCount > 0) { // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) setNewMessageCount((prev) => prev + newCount); } }, [messages, viewKey, virtualizer]); // ---- mount: always scroll to bottom if messages already loaded ---- useEffect(() => { if (messages.length > 0) { scrollToBottom('instant'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ---- highlight and scroll to todo message ---- useEffect(() => { if (!highlightedMessageId) return; const idx = messageIdToIndex.get(highlightedMessageId); if (idx === undefined) return; virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); // 高亮 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, messageIdToIndex, virtualizer]); // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度; // 滚动停止后或对话列宽度变化(拖拽调宽/折叠侧栏)时,换行行可能保留 120px 估值导致行重叠。 // 在这两个时机绕过门控,直接对已渲染行调用 resizeItem 强制纠正。 const remeasureRows = useCallback(() => { const container = containerRef.current; if (!container) return; for (const vi of virtualizer.getVirtualItems()) { const node = container.querySelector(`[data-index="${vi.index}"]`); if (node) { const h = Math.round(node.getBoundingClientRect().height); if (h > 0) virtualizer.resizeItem(vi.index, h); } } }, [virtualizer]); // 宽度变化后强制重测(含一次 rAF 补测同提交内的重 stamped 行) useEffect(() => { const container = containerRef.current; if (!container || typeof ResizeObserver === 'undefined') return; let lastWidth = container.clientWidth; const ro = new ResizeObserver(() => { const w = container.clientWidth; if (w === lastWidth) return; lastWidth = w; remeasureRows(); requestAnimationFrame(remeasureRows); }); ro.observe(container); return () => ro.disconnect(); }, [virtualizer, remeasureRows]); // 滚动停止后补测一次(滚动期间被门控跳过的行在此纠正) const isScrolling = virtualizer.isScrolling; useEffect(() => { if (isScrolling) return; const id = requestAnimationFrame(remeasureRows); return () => cancelAnimationFrame(id); }, [isScrolling, remeasureRows]); // ---- empty state ---- if (messages.length === 0) { return (

开始新的对话

在下方输入消息开始与 AI 助手聊天

); } // ---- main render ---- const virtualItems = virtualizer.getVirtualItems(); const totalSize = virtualizer.getTotalSize(); return (
{/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */}
{virtualItems.map((vi) => { const message = messages[vi.index]; return (
); })}
{/* 浮动导航按钮 — 底部居中并排 */} {showScrollToBottom && (
{/* 回到顶部 */} {/* 回到底部 */}
)}
); }