修复:完善流式输出滚动跟随

This commit is contained in:
Zhu Junhao
2026-09-05 10:37:43 +08:00
parent 4615df3f90
commit 8c3883e356
2 changed files with 293 additions and 8 deletions

View File

@@ -2,7 +2,7 @@
import { defineComponent, h } from 'vue' import { defineComponent, h } from 'vue'
import { flushPromises, shallowMount } from '@vue/test-utils' import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../api' import type { AgentEvent } from '../api'
import ProjectPage from './ProjectPage.vue' import ProjectPage from './ProjectPage.vue'
@@ -13,6 +13,7 @@ let outputResizeCallback: ResizeObserverCallback | null = null
const observeOutputMock = vi.fn() const observeOutputMock = vi.fn()
const unobserveOutputMock = vi.fn() const unobserveOutputMock = vi.fn()
const disconnectOutputObserverMock = vi.fn() const disconnectOutputObserverMock = vi.fn()
const mountedWrappers: Array<{ unmount: () => void }> = []
/** /**
* JSDOM 不提供 ResizeObserver这里保留组件注册的回调模拟流式 Markdown * JSDOM 不提供 ResizeObserver这里保留组件注册的回调模拟流式 Markdown
@@ -139,7 +140,7 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
throw new Error(`未处理的测试请求:${url}`) throw new Error(`未处理的测试请求:${url}`)
}) })
return shallowMount(ProjectPage, { const wrapper = shallowMount(ProjectPage, {
global: { global: {
stubs: { stubs: {
'el-button': ElButtonStub, 'el-button': ElButtonStub,
@@ -151,8 +152,16 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
} }
} }
}) })
mountedWrappers.push(wrapper)
return wrapper
} }
afterEach(() => {
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
})
describe('ProjectPage 模型切换', () => { describe('ProjectPage 模型切换', () => {
beforeEach(() => { beforeEach(() => {
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。 // Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
@@ -280,12 +289,112 @@ describe('ProjectPage 项目工作区', () => {
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' }) expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false) expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标,
// 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 })
outputResizeCallback!([], {} as ResizeObserver)
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。 // 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 }) Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
window.dispatchEvent(new Event('scroll')) window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false) expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值,
// 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 })
outputResizeCallback!([], {} as ResizeObserver)
await wrapper.vm.$nextTick()
expect(scrollTo).not.toHaveBeenCalled()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 }))
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 在输入控件内使用方向键不应改变页面的自动跟随状态。
const modelInput = document.createElement('input')
document.body.appendChild(modelInput)
modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
modelInput.remove()
// 控件内部的图标/子节点事件也不应触发页面滚动状态切换。
const control = document.createElement('button')
const icon = document.createElement('span')
control.appendChild(icon)
document.body.appendChild(control)
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
control.remove()
// 空格键同样会向下翻页,回到底部后应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 })
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 向下拖动滚动条并释放到底部时才恢复跟随。
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。
window.dispatchEvent(new TouchEvent('touchmove'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
await wrapper.get('.back-to-bottom').trigger('click')
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。 // 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
scrollTo.mockClear() scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 }) Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
@@ -294,6 +403,17 @@ describe('ProjectPage 项目工作区', () => {
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
// 即使新内容只让底部前移 20px也必须记录程序目标防止中间 scroll 事件误停跟随。
scrollTo.mockClear()
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。 // 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
scrollTo.mockClear() scrollTo.mockClear()
streamCallback!([{ streamCallback!([{

View File

@@ -62,8 +62,17 @@ let loadVersion = 0
let previousScrollY = 0 let previousScrollY = 0
let hasObservedScrollPosition = false let hasObservedScrollPosition = false
let pendingFollowScrollTarget: number | null = null let pendingFollowScrollTarget: number | null = null
type UserScrollIntent = 'none' | 'up' | 'down'
let userScrollIntent: UserScrollIntent = 'none'
let touchStartY: number | null = null
let scrollbarDragLastY: number | null = null
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
let scrollbarDragActive = false
const folderInput = ref<HTMLInputElement | null>(null) const folderInput = ref<HTMLInputElement | null>(null)
const LATEST_OUTPUT_THRESHOLD = 48 const LATEST_OUTPUT_THRESHOLD = 48
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
const projectId = computed(() => String(route.params.id || '')) const projectId = computed(() => String(route.params.id || ''))
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT') const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
@@ -131,6 +140,11 @@ async function load(id: string) {
previousScrollY = window.scrollY previousScrollY = window.scrollY
hasObservedScrollPosition = false hasObservedScrollPosition = false
pendingFollowScrollTarget = null pendingFollowScrollTarget = null
userScrollIntent = 'none'
touchStartY = null
scrollbarDragLastY = null
scrollbarDragDirection = 'none'
scrollbarDragActive = false
stopStream?.() stopStream?.()
stopStream = null stopStream = null
project.value = null project.value = null
@@ -480,17 +494,20 @@ function updateScrollState() {
const completingFollowScroll = pendingFollowScrollTarget !== null const completingFollowScroll = pendingFollowScrollTarget !== null
if (completingFollowScroll) { if (completingFollowScroll) {
// 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。 // 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD // 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
if (reachedRequestedTarget) {
pendingFollowScrollTarget = null pendingFollowScrollTarget = null
followsLatestOutput.value = true followsLatestOutput.value = true
} }
} else if (scrollingUp) { } else if (scrollingUp && userScrollIntent !== 'down') {
followsLatestOutput.value = false followsLatestOutput.value = false
} else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { userScrollIntent = 'up'
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。 // 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
followsLatestOutput.value = true followsLatestOutput.value = true
userScrollIntent = 'none'
} }
previousScrollY = currentScrollY previousScrollY = currentScrollY
hasObservedScrollPosition = true hasObservedScrollPosition = true
@@ -506,7 +523,9 @@ function updateScrollState() {
function moveViewportToLatestOutput(behavior?: ScrollBehavior) { function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
const scrollHeight = document.documentElement.scrollHeight const scrollHeight = document.documentElement.scrollHeight
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight) const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= LATEST_OUTPUT_THRESHOLD // 所有程序滚动箭头点击、事件更新、ResizeObserver统一只在真正抵达目标时
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
? null ? null
: targetScrollY : targetScrollY
hasObservedScrollPosition = true hasObservedScrollPosition = true
@@ -518,9 +537,137 @@ function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */ /** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
function scrollToBottom() { function scrollToBottom() {
followsLatestOutput.value = true followsLatestOutput.value = true
userScrollIntent = 'none'
touchStartY = null
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
moveViewportToLatestOutput('auto') moveViewportToLatestOutput('auto')
} }
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null
return Boolean(element && (
(element instanceof HTMLElement && element.isContentEditable)
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
))
}
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
function restoreFollowIfAtLatest() {
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
followsLatestOutput.value = true
userScrollIntent = 'none'
return true
}
return false
}
/**
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
*
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
*/
function handleUserWheel(event: WheelEvent) {
if (isInteractiveTarget(event.target)) return
if (event.deltaY !== 0) {
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
// 事件可能在用户上滚后再次把页面状态恢复到底部。
pendingFollowScrollTarget = null
}
if (event.deltaY < 0) {
followsLatestOutput.value = false
userScrollIntent = 'up'
} else if (event.deltaY > 0) {
userScrollIntent = 'down'
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
restoreFollowIfAtLatest()
}
}
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
function handleUserScrollbarDrag(event: MouseEvent) {
const scrollbarStart = document.documentElement.clientWidth
if (event.clientX >= scrollbarStart) {
followsLatestOutput.value = false
pendingFollowScrollTarget = null
scrollbarDragLastY = event.clientY
scrollbarDragDirection = 'none'
scrollbarDragActive = true
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
userScrollIntent = 'up'
}
}
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
function handleUserScrollbarMove(event: MouseEvent) {
if (!scrollbarDragActive || scrollbarDragLastY === null) return
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
scrollbarDragLastY = event.clientY
}
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
function handleUserMouseUp() {
if (!scrollbarDragActive) return
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
scrollbarDragActive = false
scrollbarDragLastY = null
const draggedUp = scrollbarDragDirection === 'up'
scrollbarDragDirection = 'none'
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
followsLatestOutput.value = true
userScrollIntent = 'none'
}
}
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
function handleUserTouchStart(event: TouchEvent) {
touchStartY = event.touches[0]?.clientY ?? null
}
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
function handleUserTouchMove(event: TouchEvent) {
if (isInteractiveTarget(event.target)) return
pendingFollowScrollTarget = null
const currentY = event.touches[0]?.clientY
if (touchStartY === null || currentY === undefined) {
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
followsLatestOutput.value = false
userScrollIntent = 'up'
return
}
const pageScrollDelta = touchStartY - currentY
if (pageScrollDelta < 0) {
followsLatestOutput.value = false
userScrollIntent = 'up'
} else if (pageScrollDelta > 0) {
userScrollIntent = 'down'
restoreFollowIfAtLatest()
}
}
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
function handleUserTouchEnd() {
touchStartY = null
}
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
function handleUserKeydown(event: KeyboardEvent) {
if (isInteractiveTarget(event.target)) return
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
followsLatestOutput.value = false
pendingFollowScrollTarget = null
userScrollIntent = 'up'
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
pendingFollowScrollTarget = null
userScrollIntent = 'down'
restoreFollowIfAtLatest()
}
}
watch(projectId, id => { if (id) void load(id) }, { immediate: true }) watch(projectId, id => { if (id) void load(id) }, { immediate: true })
watch(() => events.value.length, async () => { watch(() => events.value.length, async () => {
await nextTick() await nextTick()
@@ -543,6 +690,15 @@ onMounted(() => {
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value) if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
window.addEventListener('scroll', updateScrollState, { passive: true }) window.addEventListener('scroll', updateScrollState, { passive: true })
window.addEventListener('wheel', handleUserWheel, { passive: true })
window.addEventListener('mousedown', handleUserScrollbarDrag)
window.addEventListener('mousemove', handleUserScrollbarMove)
window.addEventListener('mouseup', handleUserMouseUp)
window.addEventListener('touchstart', handleUserTouchStart, { passive: true })
window.addEventListener('touchmove', handleUserTouchMove, { passive: true })
window.addEventListener('touchend', handleUserTouchEnd, { passive: true })
window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true })
window.addEventListener('keydown', handleUserKeydown)
updateScrollState() updateScrollState()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -551,6 +707,15 @@ onBeforeUnmount(() => {
outputResizeObserver?.disconnect() outputResizeObserver?.disconnect()
outputResizeObserver = null outputResizeObserver = null
window.removeEventListener('scroll', updateScrollState) window.removeEventListener('scroll', updateScrollState)
window.removeEventListener('wheel', handleUserWheel)
window.removeEventListener('mousedown', handleUserScrollbarDrag)
window.removeEventListener('mousemove', handleUserScrollbarMove)
window.removeEventListener('mouseup', handleUserMouseUp)
window.removeEventListener('touchstart', handleUserTouchStart)
window.removeEventListener('touchmove', handleUserTouchMove)
window.removeEventListener('touchend', handleUserTouchEnd)
window.removeEventListener('touchcancel', handleUserTouchEnd)
window.removeEventListener('keydown', handleUserKeydown)
}) })
</script> </script>