From 028f60557eb7f10bf14d28f5c73cbff644aae5b1 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 18 Aug 2026 15:33:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=88=87=E6=8D=A2=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E4=B8=8E=E9=A1=B6=E9=83=A8=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=BB=9A=E5=88=B0=E9=A6=96=E6=9D=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动态行高缓存按消息 id 键控,避免子智能体视图高度污染主视图;恢复后两帧 rAF 内不回写保存位置。 顶部按钮链式贴顶:动画落定显式续链,分页 prepend 时贴到新顶部,直到真正首条消息;关闭 virtualizer 行高修正的 scrollTop 自动补偿。 用户滚动输入/回底/待办高亮/视图切换/分页耗尽时清除贴顶意图。 --- web/src/components/Chat/MessageList.tsx | 103 +++++++++++++++++++++--- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index dde55f5..02cc387 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -47,6 +47,13 @@ export const MessageList = memo(function MessageList({ const onLoadOlderRef = useRef(onLoadOlder); onLoadOlderRef.current = onLoadOlder; + // hasMoreOlder 镜像:供 layout effect 的 prepend 分支判断分页是否耗尽。 + // 不进依赖数组——否则 hasMoreOlder 单独变化会重跑 effect,落入同视图 + // 分支的滚底逻辑(lastMessage 为 user 时 scrollToIndex 到底),把 + // 正在顶部浏览历史的用户拽到底部。 + const hasMoreOlderRef = useRef(hasMoreOlder); + hasMoreOlderRef.current = hasMoreOlder; + // 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。 // 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变, // 不应计入 newMessageCount。 @@ -55,6 +62,17 @@ export const MessageList = memo(function MessageList({ // Per-view scroll position memory const scrollPositionsRef = useRef>(new Map()); + // 滚动位置恢复期标志:恢复 scrollTop 后 virtualizer 会对可见行重测并做 + // 滚动纠正,期间的 scroll 事件不应回写 scrollPositions(否则中间值会 + // 覆盖掉原始保存位置,下次切回时锚点已丢失)。 + const restoringRef = useRef(false); + + // "顶部"按钮的链式贴顶意图:滚到已加载内容的顶部只是中间态——触顶 + // 加载的历史消息 prepend 后不做锚定补偿,而是继续贴到新顶部,链式 + // 加载直到真正首条消息(hasMoreOlder 耗尽)。用户滚动输入或视图切换 + // 即取消意图,恢复正常锚定语义。 + const scrollToTopIntentRef = useRef(false); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); @@ -65,12 +83,24 @@ export const MessageList = memo(function MessageList({ getScrollElement: () => containerRef.current, estimateSize: () => 120, overscan: 6, + // 动态高度缓存按消息 id(而非 index)存取:主/子智能体视图共用同一个 + // virtualizer 实例,若按 index 键控,访问子智能体视图会用其行高污染主 + // 视图的高度缓存,返回主视图恢复 scrollTop 时像素偏移无法映射回原消息。 + getItemKey: (i) => messages[i]?.id ?? i, measureElement: typeof window !== 'undefined' && navigator.userAgent.includes('Firefox') ? (el) => el.getBoundingClientRect().height : undefined, }); + // 禁用 virtualizer 在行高实测修正时对 scrollTop 的自动补偿写入 + // (__resizeItem → applyScrollAdjustment):该写入会打断滚顶动画、把链式 + // 贴顶从 scrollTop=0 推离,且其触发的 scroll 事件会被误判为用户接管。 + // scrollTop 的写手全部由本组件显式管理:prepend 锚定(+=)、链式贴顶 + // (=0)、滚底(scrollToIndex)。注意:该开关是实例属性而非 options + // 字段(virtual-core 3.x 类型声明),故在此赋值。 + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; + // 消息 id → virtualizer index 映射,用于 highlight 滚动定位。 // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。 const messageIdToIndex = useMemo(() => { @@ -91,6 +121,7 @@ export const MessageList = memo(function MessageList({ const scrollToBottom = useCallback( (behavior: ScrollBehavior = 'smooth') => { stopScrollTopAnimation(); + scrollToTopIntentRef.current = false; isAtBottomRef.current = true; setShowScrollToBottom(false); setNewMessageCount(0); @@ -109,8 +140,13 @@ export const MessageList = memo(function MessageList({ const el = containerRef.current; if (!el) return; stopScrollTopAnimation(); + scrollToTopIntentRef.current = true; const from = el.scrollTop; - if (from <= 0) return; + if (from <= 0) { + // 已在已加载内容的顶部:直接补触发一次分页(有更早消息则进入链式贴顶) + onLoadOlderRef.current?.(); + return; + } const duration = Math.min(800, 250 + from / 8); const start = performance.now(); const easeInOutCubic = (t: number) => @@ -118,7 +154,16 @@ export const MessageList = memo(function MessageList({ 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; + if (p < 1) { + scrollTopRafRef.current = requestAnimationFrame(step); + } else { + scrollTopRafRef.current = 0; + // 陷阱:ease 末段每帧位移是亚像素,浏览器将 scrollTop snap 到 0 后, + // 最终的 scrollTop = 0 是无变化赋值,不派发 scroll 事件 → 触顶加载 + // 永远不会被 handleScroll 触发。动画落定后必须显式续链。 + // (与 scroll 事件路径可能双发一次:requestLoadOlder 内部幂等,无害) + if (scrollToTopIntentRef.current) onLoadOlderRef.current?.(); + } }; scrollTopRafRef.current = requestAnimationFrame(step); }, [stopScrollTopAnimation]); @@ -129,6 +174,12 @@ export const MessageList = memo(function MessageList({ const el = containerRef.current; if (!el) return; + // 注意:此处不做"scrollTop>0 即取消贴顶意图"的启发式判断——程序化 + // scrollTop 写入(锚定补偿、virtualizer 纠正残留)也会产生离开顶部的 + // scroll 事件,启发式无法区分用户与程序,会误杀链式贴顶。意图的取消 + // 只由显式用户输入事件(wheel/touchstart/pointerdown,见下方监听)、 + // scrollToBottom、待办高亮、视图切换与分页耗尽负责。 + // 触顶加载更早历史:滚顶动画运行中不触发(动画会持续覆写 scrollTop, // 与 prepend 锚定补偿互相打架);动画落定在 scrollTop=0 时会发出最后一次 // scroll 事件,此刻 rafRef 已清零,可正常触发。重复触发由 hook 内 @@ -141,8 +192,9 @@ export const MessageList = memo(function MessageList({ const nearBottom = distanceFromBottom < 120; // Save scroll position for current view + // 恢复期不回写:此时 scrollTop 是刚恢复的旧值或重测纠正的中间值 const key = viewKeyRef.current; - if (key) { + if (key && !restoringRef.current) { scrollPositionsRef.current.set(key, el.scrollTop); } @@ -193,8 +245,21 @@ export const MessageList = memo(function MessageList({ const prevH = prevScrollHeightRef.current; const newH = el!.scrollHeight; if (newH > prevH) { - el!.scrollTop += newH - prevH; + if (scrollToTopIntentRef.current) { + // "顶部"链式贴顶:不做锚定补偿,直接贴到新顶部 + const wasAtTop = el!.scrollTop === 0; + el!.scrollTop = 0; + // 陷阱:scrollTop 已为 0 时赋值不触发 scroll 事件,触顶加载链会 + // 在首批后静默中断。此处显式续链(loading/hasMore 守卫在 + // requestLoadOlder 内部吸收);wasAtTop=false 时赋值会派发 + // scroll 事件,走 handleScroll 正常续链,避免双重请求。 + if (wasAtTop && hasMoreOlderRef.current) onLoadOlderRef.current?.(); + } else { + el!.scrollTop += newH - prevH; + } } + // 分页耗尽:贴顶意图已达成,清除标志恢复正常锚定语义 + if (!hasMoreOlderRef.current) scrollToTopIntentRef.current = false; prevScrollHeightRef.current = newH; prevMessageCountRef.current = messages.length; return; @@ -204,10 +269,19 @@ export const MessageList = memo(function MessageList({ if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position stopScrollTopAnimation(); + scrollToTopIntentRef.current = false; prevMessageCountRef.current = messages.length; const key = viewKey ?? ''; const savedPos = scrollPositionsRef.current.get(key); if (savedPos !== undefined && containerRef.current) { + // 恢复期跨越两帧 rAF:第一帧完成布局与可见行重测,第二帧让 + // virtualizer 的滚动纠正落定,期间 handleScroll 不回写保存位置 + restoringRef.current = true; + requestAnimationFrame(() => + requestAnimationFrame(() => { + restoringRef.current = false; + }), + ); containerRef.current.scrollTop = savedPos; const el = containerRef.current; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; @@ -267,6 +341,7 @@ export const MessageList = memo(function MessageList({ if (idx === undefined) return; stopScrollTopAnimation(); + scrollToTopIntentRef.current = false; virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); // 高亮 class 需等 DOM 渲染后操作 @@ -284,18 +359,26 @@ export const MessageList = memo(function MessageList({ // ---- 滚顶动画生命周期 ---- - // 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权 + // 用户手动滚动输入(滚轮/触摸/点击)时打断滚顶动画并取消链式贴顶意图, + // 交还滚动控制权 + const handleUserScrollInput = useCallback(() => { + stopScrollTopAnimation(); + scrollToTopIntentRef.current = false; + }, [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 }); + el.addEventListener('wheel', handleUserScrollInput, { passive: true }); + el.addEventListener('touchstart', handleUserScrollInput, { passive: true }); + el.addEventListener('pointerdown', handleUserScrollInput, { passive: true }); return () => { - el.removeEventListener('wheel', stopScrollTopAnimation); - el.removeEventListener('touchstart', stopScrollTopAnimation); + el.removeEventListener('wheel', handleUserScrollInput); + el.removeEventListener('touchstart', handleUserScrollInput); + el.removeEventListener('pointerdown', handleUserScrollInput); }; - }, [hasMessages, stopScrollTopAnimation]); + }, [hasMessages, handleUserScrollInput]); // 卸载时取消未完成的动画 useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);