diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 2e27e892..43cf5658 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -1215,6 +1215,52 @@ const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpan scrollRef, { stopScroll } ); + +// —— 运行中逐帧贴底锁 —— +// 背景:stick 库的 resize 追底在 ResizeObserver 回调里再经 rAF 才真正写 scrollTop, +// 且它观察的 .messages-flow 自身高度要等 virtua 回写占位高度后才变化,整体滞后内容 +// 增长 1~2 帧。运行中块高度过渡(思考块自动展开/流式增长/手动展开)的每一帧会先按 +// 新高度绘制、下一帧才被追底拉回,表现为「块外框先动、内部文字慢一步」的不同步。 +// 这里在对话运行期间持有 rAF(绘制前强制读取 scrollHeight 并同步吸附底部),让高度 +// 增长与滚动补偿落在同一帧。用户上滚脱离锁定(escapedFromLock/isAtBottom 变 false) +// 时自动停止吸附,滚回底部后自动恢复;非运行期仍由 useBlockExpansionAnchor 处理。 +let liveStickLockRaf: number | null = null; + +function liveStickLockTick() { + liveStickLockRaf = null; + if (!props.streamingMessage) return; + const el = scrollRef.value; + if (el && isAtBottom.value && !escapedFromLock.value) { + const maxTop = el.scrollHeight - el.clientHeight; + if (maxTop - el.scrollTop > 1) { + markProgrammaticHint('ChatArea.liveStickLock'); + el.scrollTop = maxTop; + } + } + liveStickLockRaf = requestAnimationFrame(liveStickLockTick); +} + +function stopLiveStickLock() { + if (liveStickLockRaf !== null) { + cancelAnimationFrame(liveStickLockRaf); + liveStickLockRaf = null; + } +} + +watch( + () => props.streamingMessage, + (streaming) => { + if (streaming) { + if (liveStickLockRaf === null) { + liveStickLockRaf = requestAnimationFrame(liveStickLockTick); + } + } else { + stopLiveStickLock(); + } + }, + { immediate: true } +); + const thinkingRefs = new Map(); let scrollbarResizeObserver: ResizeObserver | null = null; let bounceTraceCount = 0; @@ -2082,6 +2128,7 @@ watch(scrollRef, () => { onBeforeUnmount(() => { detachBounceListener(); stopBlockExpansionAnchors(); + stopLiveStickLock(); if (scrollbarResizeObserver) { scrollbarResizeObserver.disconnect(); scrollbarResizeObserver = null;