fix: 恢复最新消息自动跟随
点击回到底部后重新启用尾部跟随,并避免程序滚动过程被误判为用户上滚。监听流式内容尺寸变化,确保后续消息继续贴住最新位置。
This commit is contained in:
@@ -3,9 +3,39 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent } from '../api'
|
||||
import ProjectPage from './ProjectPage.vue'
|
||||
|
||||
type StreamEventsCallback = (batch: AgentEvent[]) => void
|
||||
|
||||
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', () => ({
|
||||
useRoute: () => ({ params: { id: 'project-1' } }),
|
||||
@@ -14,7 +44,12 @@ vi.mock('vue-router', () => ({
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
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', () => ({
|
||||
@@ -160,6 +195,12 @@ describe('ProjectPage 模型切换', () => {
|
||||
describe('ProjectPage 项目工作区', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
streamEventsMock.mockReset()
|
||||
streamEventsMock.mockReturnValue(vi.fn())
|
||||
outputResizeCallback = null
|
||||
observeOutputMock.mockReset()
|
||||
unobserveOutputMock.mockReset()
|
||||
disconnectOutputObserverMock.mockReset()
|
||||
})
|
||||
|
||||
it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => {
|
||||
@@ -205,4 +246,67 @@ describe('ProjectPage 项目工作区', () => {
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Document, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ArrowDown } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AgentTimeline from '../components/AgentTimeline.vue'
|
||||
import MaterialAskCard from '../components/MaterialAskCard.vue'
|
||||
@@ -46,7 +47,7 @@ const controlLoading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const historyLoading = ref(true)
|
||||
const streamError = ref('')
|
||||
const showBackToBottom = ref(false)
|
||||
const followsLatestOutput = ref(true)
|
||||
const currentModelConfigId = ref('')
|
||||
const modelPickerVisible = ref(false)
|
||||
const modelPickerLoading = ref(false)
|
||||
@@ -54,9 +55,15 @@ const modelPickerMode = ref<'resume' | 'switch'>('resume')
|
||||
const selectedModelConfigId = ref('')
|
||||
const modelCatalog = ref<ModelOption[]>([])
|
||||
const selectableModels = ref<ModelOption[]>([])
|
||||
const outputContainer = ref<HTMLElement | null>(null)
|
||||
let stopStream: (() => void) | null = null
|
||||
let outputResizeObserver: ResizeObserver | null = null
|
||||
let loadVersion = 0
|
||||
let previousScrollY = 0
|
||||
let hasObservedScrollPosition = false
|
||||
let pendingFollowScrollTarget: number | null = null
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const LATEST_OUTPUT_THRESHOLD = 48
|
||||
|
||||
const projectId = computed(() => String(route.params.id || ''))
|
||||
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) {
|
||||
const version = ++loadVersion
|
||||
historyLoading.value = true
|
||||
followsLatestOutput.value = true
|
||||
previousScrollY = window.scrollY
|
||||
hasObservedScrollPosition = false
|
||||
pendingFollowScrollTarget = null
|
||||
stopStream?.()
|
||||
stopStream = 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
|
||||
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
|
||||
*/
|
||||
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() {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })
|
||||
followsLatestOutput.value = true
|
||||
moveViewportToLatestOutput('auto')
|
||||
}
|
||||
|
||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||
watch(() => events.value.length, async () => {
|
||||
const followsTail = !showBackToBottom.value
|
||||
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(() => {
|
||||
loadVersion++
|
||||
stopStream?.()
|
||||
outputResizeObserver?.disconnect()
|
||||
outputResizeObserver = null
|
||||
window.removeEventListener('scroll', updateScrollState)
|
||||
})
|
||||
</script>
|
||||
@@ -527,7 +601,7 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</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-else-if="!events.length" class="material-start">
|
||||
<h2>企业材料</h2>
|
||||
@@ -614,7 +688,16 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</aside>
|
||||
</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 v-else class="empty-main">新建或选择一个项目</section>
|
||||
</template>
|
||||
|
||||
@@ -191,7 +191,9 @@ a { color: inherit; text-decoration: none; }
|
||||
.artifact-row a { color: var(--blue); }
|
||||
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user