fix: 恢复最新消息自动跟随

点击回到底部后重新启用尾部跟随,并避免程序滚动过程被误判为用户上滚。监听流式内容尺寸变化,确保后续消息继续贴住最新位置。
This commit is contained in:
Zhu Junhao
2026-09-03 16:18:36 +08:00
parent 7055f62da1
commit 99d16bb3b9
3 changed files with 199 additions and 10 deletions

View File

@@ -3,9 +3,39 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../api'
import ProjectPage from './ProjectPage.vue' import ProjectPage from './ProjectPage.vue'
type StreamEventsCallback = (batch: AgentEvent[]) => void
const apiMock = vi.fn() const apiMock = vi.fn()
let outputResizeCallback: ResizeObserverCallback | null = null
const observeOutputMock = vi.fn()
const unobserveOutputMock = vi.fn()
const disconnectOutputObserverMock = vi.fn()
/**
* JSDOM 不提供 ResizeObserver这里保留组件注册的回调模拟流式 Markdown
* 在事件已经写入后仍逐帧增高的真实浏览器行为。
*/
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
outputResizeCallback = callback
}
observe = observeOutputMock
unobserve = unobserveOutputMock
disconnect = disconnectOutputObserverMock
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
const streamEventsMock = vi.fn((
_projectId: string,
_after: number,
_onEvents: StreamEventsCallback,
_onError: (error: Error) => void
) => vi.fn())
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({ params: { id: 'project-1' } }), useRoute: () => ({ params: { id: 'project-1' } }),
@@ -14,7 +44,12 @@ vi.mock('vue-router', () => ({
vi.mock('../api', () => ({ vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args), api: (...args: unknown[]) => apiMock(...args),
streamEvents: vi.fn(() => vi.fn()) streamEvents: (
projectId: string,
after: number,
onEvents: StreamEventsCallback,
onError: (error: Error) => void
) => streamEventsMock(projectId, after, onEvents, onError)
})) }))
vi.mock('../eventCache', () => ({ vi.mock('../eventCache', () => ({
@@ -160,6 +195,12 @@ describe('ProjectPage 模型切换', () => {
describe('ProjectPage 项目工作区', () => { describe('ProjectPage 项目工作区', () => {
beforeEach(() => { beforeEach(() => {
apiMock.mockReset() apiMock.mockReset()
streamEventsMock.mockReset()
streamEventsMock.mockReturnValue(vi.fn())
outputResizeCallback = null
observeOutputMock.mockReset()
unobserveOutputMock.mockReset()
disconnectOutputObserverMock.mockReset()
}) })
it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => { it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => {
@@ -205,4 +246,67 @@ describe('ProjectPage 项目工作区', () => {
expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a') expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a')
}) })
it('用户向上滚动后暂停跟随新输出,并可通过箭头回到最新位置', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1800 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1200 })
const wrapper = mountProject('RUNNING')
await flushPromises()
scrollTo.mockClear()
// 即使只向上移动 20px也已代表用户主动回看历史下一批输出不能把页面拉回底部。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
const latestButton = wrapper.get('.back-to-bottom')
expect(latestButton.attributes('aria-label')).toBe('转到最新输出')
const streamCallback = streamEventsMock.mock.calls[0]?.[2]
expect(streamCallback).toBeTypeOf('function')
streamCallback!([{
id: 1,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '新输出' },
createdAt: '2026-09-03T10:00:00Z'
}])
await flushPromises()
expect(scrollTo).not.toHaveBeenCalled()
await latestButton.trigger('click')
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
expect(outputResizeCallback).not.toBeNull()
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
scrollTo.mockClear()
streamCallback!([{
id: 2,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '后续输出' },
createdAt: '2026-09-03T10:00:01Z'
}])
await flushPromises()
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
})
}) })

View File

@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Document, UploadFilled } from '@element-plus/icons-vue' import { Document, UploadFilled } from '@element-plus/icons-vue'
import { ArrowDown } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AgentTimeline from '../components/AgentTimeline.vue' import AgentTimeline from '../components/AgentTimeline.vue'
import MaterialAskCard from '../components/MaterialAskCard.vue' import MaterialAskCard from '../components/MaterialAskCard.vue'
@@ -46,7 +47,7 @@ const controlLoading = ref(false)
const deleting = ref(false) const deleting = ref(false)
const historyLoading = ref(true) const historyLoading = ref(true)
const streamError = ref('') const streamError = ref('')
const showBackToBottom = ref(false) const followsLatestOutput = ref(true)
const currentModelConfigId = ref('') const currentModelConfigId = ref('')
const modelPickerVisible = ref(false) const modelPickerVisible = ref(false)
const modelPickerLoading = ref(false) const modelPickerLoading = ref(false)
@@ -54,9 +55,15 @@ const modelPickerMode = ref<'resume' | 'switch'>('resume')
const selectedModelConfigId = ref('') const selectedModelConfigId = ref('')
const modelCatalog = ref<ModelOption[]>([]) const modelCatalog = ref<ModelOption[]>([])
const selectableModels = ref<ModelOption[]>([]) const selectableModels = ref<ModelOption[]>([])
const outputContainer = ref<HTMLElement | null>(null)
let stopStream: (() => void) | null = null let stopStream: (() => void) | null = null
let outputResizeObserver: ResizeObserver | null = null
let loadVersion = 0 let loadVersion = 0
let previousScrollY = 0
let hasObservedScrollPosition = false
let pendingFollowScrollTarget: number | null = null
const folderInput = ref<HTMLInputElement | null>(null) const folderInput = ref<HTMLInputElement | null>(null)
const LATEST_OUTPUT_THRESHOLD = 48
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')
@@ -120,6 +127,10 @@ function syncRunState(run: RunSummary, fallbackModelConfigId = '') {
async function load(id: string) { async function load(id: string) {
const version = ++loadVersion const version = ++loadVersion
historyLoading.value = true historyLoading.value = true
followsLatestOutput.value = true
previousScrollY = window.scrollY
hasObservedScrollPosition = false
pendingFollowScrollTarget = null
stopStream?.() stopStream?.()
stopStream = null stopStream = null
project.value = null project.value = null
@@ -458,24 +469,87 @@ async function refreshArtifacts(id = projectId.value, version = loadVersion) {
if (version === loadVersion && id === projectId.value) artifacts.value = value if (version === loadVersion && id === projectId.value) artifacts.value = value
} }
/**
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
*/
function updateScrollState() { function updateScrollState() {
showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240 const currentScrollY = window.scrollY
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
const scrollingUp = hasObservedScrollPosition && currentScrollY < previousScrollY
const completingFollowScroll = pendingFollowScrollTarget !== null
if (completingFollowScroll) {
// 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD
if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
pendingFollowScrollTarget = null
followsLatestOutput.value = true
}
} else if (scrollingUp) {
followsLatestOutput.value = false
} else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
followsLatestOutput.value = true
}
previousScrollY = currentScrollY
hasObservedScrollPosition = true
} }
/**
* 将窗口移动到当前文档底部,并记录本次程序滚动的目标位置。
*
* <p>浏览器派发 scroll 事件晚于 scrollTo 调用,大文档还可能先派发一个或多个
* 尚未到达底部的中间事件。目标位置必须单独保存,不能写入 previousScrollY
* 否则中间坐标会因为小于目标值而被误判为用户向上滚动。</p>
*/
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
const scrollHeight = document.documentElement.scrollHeight
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= LATEST_OUTPUT_THRESHOLD
? null
: targetScrollY
hasObservedScrollPosition = true
const options: ScrollToOptions = { top: scrollHeight }
if (behavior) options.behavior = behavior
window.scrollTo(options)
}
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
function scrollToBottom() { function scrollToBottom() {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }) followsLatestOutput.value = true
moveViewportToLatestOutput('auto')
} }
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 () => {
const followsTail = !showBackToBottom.value
await nextTick() await nextTick()
if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight }) // nextTick 后重新读取实时状态,确保用户在本轮渲染期间向上滚动也能立即中止跟随。
if (followsLatestOutput.value) moveViewportToLatestOutput()
})
watch(outputContainer, (current, previous) => {
if (!outputResizeObserver) return
if (previous) outputResizeObserver.unobserve(previous)
if (current) outputResizeObserver.observe(current)
}, { flush: 'post' })
onMounted(() => {
/**
* Markstream 的平滑流式渲染会在 Vue nextTick 结束后继续逐帧增加内容高度。
* 监听真实输出容器的尺寸变化,才能在同一批事件的渲染动画期间持续贴住底部。
*/
outputResizeObserver = new ResizeObserver(() => {
if (followsLatestOutput.value) moveViewportToLatestOutput()
})
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
window.addEventListener('scroll', updateScrollState, { passive: true })
updateScrollState()
}) })
onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true }))
onBeforeUnmount(() => { onBeforeUnmount(() => {
loadVersion++ loadVersion++
stopStream?.() stopStream?.()
outputResizeObserver?.disconnect()
outputResizeObserver = null
window.removeEventListener('scroll', updateScrollState) window.removeEventListener('scroll', updateScrollState)
}) })
</script> </script>
@@ -527,7 +601,7 @@ onBeforeUnmount(() => {
</li> </li>
</ol> </ol>
<div class="work-scroll"> <div ref="outputContainer" class="work-scroll">
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section> <section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
<section v-else-if="!events.length" class="material-start"> <section v-else-if="!events.length" class="material-start">
<h2>企业材料</h2> <h2>企业材料</h2>
@@ -614,7 +688,16 @@ onBeforeUnmount(() => {
</section> </section>
</aside> </aside>
</div> </div>
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom"></button> <button
v-if="!followsLatestOutput"
class="back-to-bottom"
type="button"
aria-label="转到最新输出"
title="转到最新输出"
@click="scrollToBottom"
>
<ArrowDown aria-hidden="true" />
</button>
</section> </section>
<section v-else class="empty-main">新建或选择一个项目</section> <section v-else class="empty-main">新建或选择一个项目</section>
</template> </template>

View File

@@ -191,7 +191,9 @@ a { color: inherit; text-decoration: none; }
.artifact-row a { color: var(--blue); } .artifact-row a { color: var(--blue); }
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; } .stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
.retry-button { display: block; margin: 28px auto 0; } .retry-button { display: block; margin: 28px auto 0; }
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; } .back-to-bottom { position: fixed; left: 50%; bottom: 28px; z-index: 4; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); transform: translateX(-50%); cursor: pointer; }
.back-to-bottom svg { width: 20px; height: 20px; }
.back-to-bottom:hover { background: #f4f7fb; }
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } .back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; } .empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
.model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; } .model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }