fix: 恢复最新消息自动跟随
点击回到底部后重新启用尾部跟随,并避免程序滚动过程被误判为用户上滚。监听流式内容尺寸变化,确保后续消息继续贴住最新位置。
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user