diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 1dedf7d..6b7fbd8 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -27,6 +27,7 @@ export function MessageList({ const containerRef = useRef(null); const isAtBottomRef = useRef(true); const prevShowBottomRef = useRef(false); + const scrollTopRafRef = useRef(0); const prevViewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey); viewKeyRef.current = viewKey; @@ -65,8 +66,16 @@ export function MessageList({ // ---- scroll helpers ---- + const stopScrollTopAnimation = useCallback(() => { + if (scrollTopRafRef.current !== 0) { + cancelAnimationFrame(scrollTopRafRef.current); + scrollTopRafRef.current = 0; + } + }, []); + const scrollToBottom = useCallback( (behavior: ScrollBehavior = 'smooth') => { + stopScrollTopAnimation(); isAtBottomRef.current = true; setShowScrollToBottom(false); setNewMessageCount(0); @@ -74,12 +83,30 @@ export function MessageList({ virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior }); } }, - [virtualizer, messages.length], + [virtualizer, messages.length, stopScrollTopAnimation], ); + // 长距离滚回顶部不能用原生 behavior:'smooth':滚动途中上方行首次被测量时, + // virtualizer 会写 scrollTop 做偏移纠正(resizeItem → applyScrollAdjustment), + // 程序化 scrollTop 写入会直接打断浏览器平滑滚动动画,消息越多越容易停在半路。 + // 改为 rAF 自驱动动画,每帧覆写 scrollTop,纠正写入下一帧即被覆盖,必达顶部。 const scrollToTop = useCallback(() => { - containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); - }, []); + const el = containerRef.current; + if (!el) return; + stopScrollTopAnimation(); + const from = el.scrollTop; + if (from <= 0) return; + const duration = Math.min(800, 250 + from / 8); + const start = performance.now(); + const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const step = (now: number) => { + const p = Math.min(1, (now - start) / duration); + el.scrollTop = from * (1 - easeInOutCubic(p)); + scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0; + }; + scrollTopRafRef.current = requestAnimationFrame(step); + }, [stopScrollTopAnimation]); // ---- scroll event: track whether user is at bottom ---- @@ -126,6 +153,7 @@ export function MessageList({ if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position + stopScrollTopAnimation(); prevMessageCountRef.current = messages.length; const key = viewKey ?? ''; const savedPos = scrollPositionsRef.current.get(key); @@ -148,6 +176,7 @@ export function MessageList({ prevMessageCountRef.current = messages.length; if (lastMessage.role === 'user' || isAtBottomRef.current) { + stopScrollTopAnimation(); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); // 用户自己发消息或已在底部时,不需要计数 if (newCount > 0) { @@ -157,7 +186,7 @@ export function MessageList({ // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) setNewMessageCount((prev) => prev + newCount); } - }, [messages, viewKey, virtualizer]); + }, [messages, viewKey, virtualizer, stopScrollTopAnimation]); // ---- mount: always scroll to bottom if messages already loaded ---- @@ -175,6 +204,7 @@ export function MessageList({ const idx = messageIdToIndex.get(highlightedMessageId); if (idx === undefined) return; + stopScrollTopAnimation(); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); // 高亮 class 需等 DOM 渲染后操作 @@ -188,7 +218,25 @@ export function MessageList({ targetElement.classList.remove('todo-highlight'); }, 2000); }); - }, [highlightedMessageId, messageIdToIndex, virtualizer]); + }, [highlightedMessageId, messageIdToIndex, virtualizer, stopScrollTopAnimation]); + + // ---- 滚顶动画生命周期 ---- + + // 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权 + const hasMessages = messages.length > 0; + useEffect(() => { + const el = containerRef.current; + if (!el) return; + el.addEventListener('wheel', stopScrollTopAnimation, { passive: true }); + el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true }); + return () => { + el.removeEventListener('wheel', stopScrollTopAnimation); + el.removeEventListener('touchstart', stopScrollTopAnimation); + }; + }, [hasMessages, stopScrollTopAnimation]); + + // 卸载时取消未完成的动画 + useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]); // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;