From 5db8fb14a42b06a7cd689da698dce0dd03a2de20 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 8 Jul 2026 15:53:10 +0800 Subject: [PATCH] fix(chat): stop history view snapping to bottom during streaming (#498) --- .../src/components/chat/MessageList.vue | 20 +- .../src/composables/chat/useStickToBottom.ts | 230 +++++++++--------- mateclaw-ui/src/types/components.d.ts | 22 ++ 3 files changed, 154 insertions(+), 118 deletions(-) create mode 100644 mateclaw-ui/src/types/components.d.ts diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index a7ef3061..0106df2b 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -200,14 +200,21 @@ const showCursorForMessage = (msg: Message) => { return isLastAssistant && msg.status === 'generating' } -// 监听消息变化,自动滚动 +// 监听消息变化,自动滚动。流式生成时 token 逐字变更会高频触发本 watcher, +// 用一个 rAF 合并同一帧内的多次变更为一次即时滚动,避免每 token 动画抖动; +// 用户上滚脱离贴底后(escapedFromLock)完全不滚动,读历史不被打断。 +let scrollRaf = 0 watch( () => props.messages, - async () => { - await nextTick() - // Don't auto-scroll while the user has scrolled up to read history. + () => { if (escapedFromLock.value) return - scrollToBottom() + if (scrollRaf) return + scrollRaf = requestAnimationFrame(async () => { + scrollRaf = 0 + await nextTick() + if (escapedFromLock.value) return + scrollToBottom({ smooth: false }) + }) }, { deep: true } ) @@ -226,7 +233,8 @@ watch( // Explicit "jump to bottom" request (button click / End key): force past the // escape lock and clear it so sticky auto-scroll resumes following new content. function goToBottom() { - escapedFromLock.value = false + // force ignores the escape lock; the composable re-pins (isAtBottom / + // escapedFromLock stay in sync) once the scroll settles. scrollToBottom({ force: true, smooth: true }) } diff --git a/mateclaw-ui/src/composables/chat/useStickToBottom.ts b/mateclaw-ui/src/composables/chat/useStickToBottom.ts index 283cd397..e3250817 100644 --- a/mateclaw-ui/src/composables/chat/useStickToBottom.ts +++ b/mateclaw-ui/src/composables/chat/useStickToBottom.ts @@ -1,6 +1,14 @@ /** * 智能滚动 Composable - * 提供"贴底/脱离锁定"的智能自动滚动体验:内容增长时自动贴底,用户上滚后释放锁定 + * 提供"贴底/脱离锁定"的智能自动滚动体验:内容增长时自动贴底,用户上滚后释放锁定。 + * + * Pin model: a single "pinned" state derived purely from the absolute distance + * from the bottom (scrollHeight - scrollTop - clientHeight). The view is pinned + * (auto-follows new content) only while the user is within `offset` of the + * bottom; scrolling up past `offset` unpins, and scrolling back within `offset` + * re-pins. No scroll-direction deltas are used — those flicker during scrollbar + * drags and caused the view to snap back to the newest message while reading + * history (issue #498). */ import { ref, computed, onMounted, onUnmounted } from 'vue' @@ -16,11 +24,11 @@ export interface StickToBottomOptions { } export interface StickToBottomReturn { - /** 是否在底部 */ + /** 是否贴底(自动跟随新内容) */ isAtBottom: import('vue').Ref /** 是否在底部附近 */ isNearBottom: import('vue').ComputedRef - /** 是否被用户滚动中断 */ + /** 是否被用户滚动中断(= 未贴底) */ escapedFromLock: import('vue').Ref /** 滚动元素引用 */ scrollRef: import('vue').Ref @@ -47,154 +55,157 @@ export function useStickToBottom( options: StickToBottomOptions = {} ): StickToBottomReturn { const opts = { ...DEFAULT_OPTIONS, ...options } - + const scrollRef = ref(null) const contentRef = ref(null) - + + // Single source of truth. `isAtBottom` = pinned (auto-follow on); + // `escapedFromLock` is its inverse, kept in sync via setPinned() so the two + // exported refs can never disagree. const isAtBottom = ref(true) const escapedFromLock = ref(false) - let isScrolling = false - let lastScrollTop = 0 + + // True only while we drive a programmatic (smooth) scroll toward the bottom. + // During that window the scroll events we ourselves generate must not be + // interpreted as user intent. A user gesture (wheel-up / pointer down) clears + // it to hand control back immediately. + let programmatic = false let isSelecting = false + let lastScrollTop = 0 + + const supportsSmooth = + typeof document !== 'undefined' && 'scrollBehavior' in document.documentElement.style + + const distanceFromBottom = (el: HTMLElement) => + el.scrollHeight - el.scrollTop - el.clientHeight + + const atBottom = (el: HTMLElement) => distanceFromBottom(el) <= opts.offset + + const setPinned = (pinned: boolean) => { + isAtBottom.value = pinned + escapedFromLock.value = !pinned + } // 是否在底部附近 const isNearBottom = computed(() => { if (!scrollRef.value) return false - const { scrollHeight, scrollTop, clientHeight } = scrollRef.value - return scrollHeight - scrollTop - clientHeight <= opts.offset + return atBottom(scrollRef.value) }) // 检查是否在底部 const checkIsAtBottom = () => { if (!scrollRef.value) return false - const { scrollHeight, scrollTop, clientHeight } = scrollRef.value - return scrollHeight - scrollTop - clientHeight <= opts.offset + return atBottom(scrollRef.value) } // 滚动到底部 const scrollToBottom = async (scrollOptions?: { force?: boolean; smooth?: boolean }) => { const { force = false, smooth = opts.smooth } = scrollOptions || {} - - if (!scrollRef.value) return - - // 如果用户已经向上滚动,且不强制滚动,则跳过 - if (!force && escapedFromLock.value) return - - // 如果正在选择文本,不滚动 - if (isSelecting) return const element = scrollRef.value + if (!element) return + + // 用户已上滚脱离贴底,且非强制:不打扰 + if (!force && escapedFromLock.value) return + // 正在选择文本:不滚动 + if (isSelecting) return + const targetScrollTop = element.scrollHeight - element.clientHeight - // 已经在底部,无需滚动 - if (element.scrollTop >= targetScrollTop - 1) return - - isScrolling = true - - if (smooth && 'scrollBehavior' in document.documentElement.style) { - // 使用原生平滑滚动 - element.scrollTo({ top: targetScrollTop, behavior: 'smooth' }) - - // 等待滚动完成 - await new Promise((resolve) => { - const startedAt = performance.now() - const checkScrollEnd = () => { - if (!isScrolling || Math.abs(element.scrollTop - targetScrollTop) < 1 - || performance.now() - startedAt > opts.duration * 2) { - isScrolling = false - lastScrollTop = element.scrollTop - resolve() - } else { - requestAnimationFrame(checkScrollEnd) - } - } - setTimeout(checkScrollEnd, opts.duration) - }) - } else { - // 直接滚动 - element.scrollTop = targetScrollTop - isScrolling = false - lastScrollTop = element.scrollTop + // 已在底部 + if (element.scrollTop >= targetScrollTop - 1) { + setPinned(true) + return } - isAtBottom.value = true + if (smooth && supportsSmooth) { + programmatic = true + element.scrollTo({ top: targetScrollTop, behavior: 'smooth' }) + + await new Promise((resolve) => { + const startedAt = performance.now() + const step = () => { + // 用户中断(programmatic 被手势清除)—— 让位 + if (!programmatic) { + resolve() + return + } + const settled = Math.abs(element.scrollTop - (element.scrollHeight - element.clientHeight)) < 1 + const timedOut = performance.now() - startedAt > opts.duration * 2 + if (settled || timedOut) { + programmatic = false + lastScrollTop = element.scrollTop + setPinned(true) + resolve() + } else { + requestAnimationFrame(step) + } + } + requestAnimationFrame(step) + }) + } else { + element.scrollTop = targetScrollTop + lastScrollTop = element.scrollTop + setPinned(true) + } } // 停止自动滚动 const stopScroll = () => { - escapedFromLock.value = true - isAtBottom.value = false + programmatic = false + setPinned(false) } - // 处理滚动事件 + // 处理滚动事件 —— 纯绝对位置判定,无方向增量 const handleScroll = () => { - if (!scrollRef.value) return - if (isScrolling) { - // User scrolled up during a programmatic scroll — release the lock so the - // view stops snapping back to the bottom. - const currentScrollTop = scrollRef.value.scrollTop - if (currentScrollTop < lastScrollTop) { - isScrolling = false - escapedFromLock.value = true - isAtBottom.value = false - } - lastScrollTop = scrollRef.value.scrollTop + const element = scrollRef.value + if (!element) return + + // 忽略我们自己的程序化滚动产生的事件 + if (programmatic) { + lastScrollTop = element.scrollTop return } + // 绝对判定:距底 <= offset 即贴底,否则脱离 + if (atBottom(element)) { + if (escapedFromLock.value) setPinned(true) + } else if (!escapedFromLock.value) { + setPinned(false) + } + lastScrollTop = element.scrollTop + } + + // 用户手势:立即接管(取消进行中的程序化滚动) + const handleUserGestureUp = () => { + programmatic = false const element = scrollRef.value - const currentScrollTop = element.scrollTop - - // 检测滚动方向 - const isScrollingUp = currentScrollTop < lastScrollTop - const isScrollingDown = currentScrollTop > lastScrollTop - - lastScrollTop = currentScrollTop - - // 向上滚动,用户想要查看历史内容,中断自动滚动 - if (isScrollingUp) { - escapedFromLock.value = true - isAtBottom.value = false - } - - // 向下滚动到底部,恢复自动滚动 - if ((isScrollingDown || checkIsAtBottom()) && isNearBottom.value) { - escapedFromLock.value = false - isAtBottom.value = true - } + if (element && !atBottom(element)) setPinned(false) } - // 处理鼠标滚轮 const handleWheel = (e: WheelEvent) => { - if (!scrollRef.value) return - - // 如果用户向上滚动,确保我们记录这个行为 - if (e.deltaY < 0) { - isScrolling = false - escapedFromLock.value = true - isAtBottom.value = false - } + if (e.deltaY < 0) handleUserGestureUp() } - // 处理鼠标/触摸开始(选择文本) + // 处理鼠标/触摸开始(拖动滚动条 / 选择文本) const handlePointerDown = () => { isSelecting = true + // 抓住滚动条即视为用户接管,取消程序化滚动 + programmatic = false } const handlePointerUp = () => { isSelecting = false - // 选择结束后检查是否在底部 + // 结束后按绝对位置复核贴底状态 setTimeout(() => { - if (checkIsAtBottom()) { - escapedFromLock.value = false - isAtBottom.value = true - } + const element = scrollRef.value + if (element) setPinned(atBottom(element)) }, 100) } const resetLock = () => { - escapedFromLock.value = false - isAtBottom.value = true + programmatic = false + setPinned(true) } // ResizeObserver 监听内容变化 @@ -205,18 +216,16 @@ export function useStickToBottom( const element = scrollRef.value - // 添加事件监听 element.addEventListener('scroll', handleScroll, { passive: true }) element.addEventListener('wheel', handleWheel, { passive: true }) element.addEventListener('mousedown', handlePointerDown) - element.addEventListener('touchstart', handlePointerDown) + element.addEventListener('touchstart', handlePointerDown, { passive: true }) document.addEventListener('mouseup', handlePointerUp) document.addEventListener('touchend', handlePointerUp) - // 监听内容变化 + // 内容变化时,仅在贴底状态下跟随(用即时滚动,避免每 token 动画抖动) if (contentRef.value && window.ResizeObserver) { resizeObserver = new ResizeObserver(() => { - // 内容变化时,如果在底部则保持滚动到底部 if (opts.enabled && isAtBottom.value && !escapedFromLock.value) { scrollToBottom({ smooth: false }) } @@ -231,19 +240,16 @@ export function useStickToBottom( }) onUnmounted(() => { - if (!scrollRef.value) return - const element = scrollRef.value - - // 移除事件监听 - element.removeEventListener('scroll', handleScroll) - element.removeEventListener('wheel', handleWheel) - element.removeEventListener('mousedown', handlePointerDown) - element.removeEventListener('touchstart', handlePointerDown) + if (element) { + element.removeEventListener('scroll', handleScroll) + element.removeEventListener('wheel', handleWheel) + element.removeEventListener('mousedown', handlePointerDown) + element.removeEventListener('touchstart', handlePointerDown) + } document.removeEventListener('mouseup', handlePointerUp) document.removeEventListener('touchend', handlePointerUp) - // 断开 ResizeObserver if (resizeObserver) { resizeObserver.disconnect() resizeObserver = null diff --git a/mateclaw-ui/src/types/components.d.ts b/mateclaw-ui/src/types/components.d.ts new file mode 100644 index 00000000..31a4b5d4 --- /dev/null +++ b/mateclaw-ui/src/types/components.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider'] + ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog'] + ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon'] + ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover'] + ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } +}