Merge pull request #5955 from deepseek-harness/fix-chat-height-reset
fix(ui-chat): reset layout on mode changes and running session returns
This commit is contained in:
commit
311fc54705
9 files changed
+421
-11
No files matched your search
@@ -635,6 +635,306 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => {
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 })
|
||||
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/chat-mode-height/', import.meta.url)), { recursive: true })
|
||||
await withScrollWorld({
|
||||
failureShot: 'screenshots/chat-mode-height/web-e2e-chat-mode-height',
|
||||
replay: [replayEntry(textStream('CHAT_MODE_REPLY', 'CHAT_MODE_DONE', 2))],
|
||||
seeds: [{ fixture, id: 'chat-mode-height-e2e' }],
|
||||
}, async (world) => {
|
||||
const { page } = world
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
const selectPreference = async (title: string, choice: string): Promise<void> => {
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const row = dialog.getByText(title, { exact: true }).locator('../..')
|
||||
await row.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: choice, exact: true }).click()
|
||||
await row.getByRole('button', { name: choice, exact: true }).waitFor()
|
||||
if (title === 'Work details') {
|
||||
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
||||
.toMatchObject({ transcriptView: choice.toLowerCase() })
|
||||
}
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0)
|
||||
await nextPaint(page)
|
||||
}
|
||||
const returnToBottom = async (): Promise<void> => {
|
||||
await wheelTranscript(page, -600)
|
||||
await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(page)
|
||||
}
|
||||
const geometry = () => page.locator('[data-conversation-scroll]').evaluate(host => ({
|
||||
height: host.scrollHeight,
|
||||
viewport: host.clientHeight,
|
||||
spacer: host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.getBoundingClientRect().height,
|
||||
}))
|
||||
await selectPreference('Work details', 'Standard')
|
||||
await openSeed(page, fixture, fixture.markers.assistant(8))
|
||||
// Hold a live reply so preference changes exercise the same fold animation as incoming content.
|
||||
const release = holdTextAfter(world, 1)
|
||||
let replyStarted = false
|
||||
try {
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_MODE_NEXT_INPUT')
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText('CHAT_MODE_REPLY', { exact: false }).waitFor()
|
||||
replyStarted = true
|
||||
await selectPreference('When to Collapse Work Details', 'On next message')
|
||||
await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor()
|
||||
await returnToBottom()
|
||||
const standard = await geometry()
|
||||
expect(standard.height).toBeGreaterThan(standard.viewport)
|
||||
expect(standard.spacer).toBe(0)
|
||||
for (let cycle = 0; cycle < 2; cycle += 1) {
|
||||
await selectPreference('Work details', 'Verbose')
|
||||
await expect.poll(async () => (await geometry()).height)
|
||||
.toBeGreaterThan(standard.height + GEOMETRY_TOLERANCE)
|
||||
await returnToBottom()
|
||||
await selectPreference('Work details', 'Standard')
|
||||
await expect.poll(async () => (await geometry()).spacer).toBe(0)
|
||||
await expect.poll(async () => Math.abs((await geometry()).height - standard.height))
|
||||
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expectBottom(page)
|
||||
await expectMarkerAboveComposer(page, 'CHAT_MODE_REPLY')
|
||||
}
|
||||
} finally {
|
||||
const settled = replyStarted ? world.scaffold.whenTurnSettled() : undefined
|
||||
release()
|
||||
await settled
|
||||
}
|
||||
assertClean(world)
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps a running Session stationary when returning with output held', async () => {
|
||||
const fixtureA = createChatScrollFixture({ markerPrefix: 'RETURN_A', title: 'CHAT_RUNNING_RETURN_A' })
|
||||
const fixtureB = createChatScrollFixture({ markerPrefix: 'RETURN_B', title: 'CHAT_RUNNING_RETURN_B', turns: 32 })
|
||||
const heldMarker = 'CHAT_RUNNING_RETURN_HELD'
|
||||
const grownMarker = 'CHAT_RUNNING_RETURN_GROWN'
|
||||
const doneMarker = 'CHAT_RUNNING_RETURN_DONE'
|
||||
const paragraphs = (count: number, prefix: string): string => Array.from({ length: count }, (_, index) =>
|
||||
`${prefix} paragraph ${index + 1}: ${'The recorded response stays readable while this Session is open. '.repeat(3)}`,
|
||||
).join('\n\n')
|
||||
const parts = [
|
||||
`${paragraphs(32, 'Initial output')}\n\n${heldMarker}\n\n`,
|
||||
`${paragraphs(12, 'Resumed output')}\n\n${grownMarker}\n\n`,
|
||||
doneMarker,
|
||||
]
|
||||
const chunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...parts.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: parts.join('') } },
|
||||
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 2_500 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/chat-running-return/', import.meta.url))
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const evidence = await mkdtemp(join(artifactRoot, 'run-'))
|
||||
await withScrollWorld({
|
||||
failureShot: 'screenshots/chat-running-return/web-e2e-chat-running-return',
|
||||
replay: [replayEntry(chunks)],
|
||||
seeds: [
|
||||
{ fixture: fixtureA, id: 'chat-running-return-a-e2e' },
|
||||
{ fixture: fixtureB, id: 'chat-running-return-b-e2e' },
|
||||
],
|
||||
}, async (world) => {
|
||||
const { page } = world
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
for (const [title, choice] of [
|
||||
['Work details', 'Standard'],
|
||||
['When to Collapse Work Details', 'On next message'],
|
||||
] as const) {
|
||||
await dialog.getByText(title, { exact: true }).locator('../..').getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: choice, exact: true }).click()
|
||||
}
|
||||
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
||||
.toMatchObject({ transcriptView: 'standard' })
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await openSeed(page, fixtureA, fixtureA.markers.assistant(fixtureA.turns))
|
||||
await expectBottom(page)
|
||||
|
||||
interface ReturnFrame {
|
||||
frame: number
|
||||
t: number
|
||||
sessionA: boolean
|
||||
markerY: number | null
|
||||
markerVisible: boolean
|
||||
scrollTop: number
|
||||
floor: number
|
||||
viewportHeight: number
|
||||
columnHeight: number
|
||||
spacer: number
|
||||
motionEnabled: boolean
|
||||
motion: string[]
|
||||
following: boolean
|
||||
running: boolean
|
||||
responseLength: number | null
|
||||
}
|
||||
const sampleReturn = async (marker: string): Promise<ReturnFrame[]> => {
|
||||
await openSeed(page, fixtureB, fixtureB.markers.assistant(fixtureB.turns))
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixtureA.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count()).toBe(1)
|
||||
// Start before the click; the first visible A frame is part of the assertion, not a setup wait.
|
||||
const trace = page.evaluate(({ marker, identity }) => new Promise<ReturnFrame[]>((resolve) => {
|
||||
const samples: ReturnFrame[] = []
|
||||
let frame = 0
|
||||
let timer = 0
|
||||
let visibleFrames = 0
|
||||
let ordinal = 0
|
||||
const finish = (): void => {
|
||||
cancelAnimationFrame(frame)
|
||||
window.clearTimeout(timer)
|
||||
document.removeEventListener('dsh-test-return-stop', finish)
|
||||
document.documentElement.dataset.returnProbe = 'done'
|
||||
resolve(samples)
|
||||
}
|
||||
const sample = (): void => {
|
||||
const host = [...document.querySelectorAll<HTMLElement>('[data-conversation-scroll]')]
|
||||
.find(element => !element.closest('[inert], [hidden]') && element.checkVisibility())
|
||||
const column = host?.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')
|
||||
if (host !== undefined && column !== null && column !== undefined) {
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composerTop = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top ?? viewport.bottom
|
||||
const node = [...column.querySelectorAll<HTMLElement>('p')]
|
||||
.find(element => element.textContent?.trim() === marker)
|
||||
const rect = node?.getBoundingClientRect()
|
||||
const sessionA = column.textContent.includes(identity)
|
||||
samples.push({
|
||||
frame: ordinal++, t: performance.now(), sessionA,
|
||||
markerY: rect === undefined ? null : rect.top - viewport.top,
|
||||
markerVisible: rect !== undefined && rect.bottom > viewport.top && rect.top < composerTop,
|
||||
scrollTop: host.scrollTop, floor: host.scrollHeight - host.clientHeight,
|
||||
viewportHeight: host.clientHeight, columnHeight: column.getBoundingClientRect().height,
|
||||
spacer: host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0,
|
||||
motionEnabled: column.hasAttribute('data-chat-motion'),
|
||||
motion: [...column.querySelectorAll<HTMLElement>('[data-chat-motion]')]
|
||||
.map(element => element.dataset.chatMotion ?? ''),
|
||||
following: host.querySelector('[data-chat-following-tail]') !== null,
|
||||
running: host.querySelector('[data-chat-running]') !== null,
|
||||
responseLength: node?.closest('[data-chat-flow-key]')?.textContent?.length ?? null,
|
||||
})
|
||||
if (sessionA) visibleFrames += 1
|
||||
}
|
||||
if (visibleFrames >= 60) finish()
|
||||
else frame = requestAnimationFrame(tick)
|
||||
}
|
||||
// A zero-delay task samples after this frame's animation callbacks, including native scrolling.
|
||||
const tick = (): void => { timer = window.setTimeout(sample, 0) }
|
||||
document.addEventListener('dsh-test-return-stop', finish, { once: true })
|
||||
document.documentElement.dataset.returnProbe = 'ready'
|
||||
frame = requestAnimationFrame(tick)
|
||||
}), { marker, identity: 'CHAT_SCROLL_RETURN_A_' })
|
||||
try {
|
||||
await page.locator('html[data-return-probe="ready"]').waitFor()
|
||||
await result.click()
|
||||
await page.locator('html[data-return-probe="done"]').waitFor({ timeout: 15_000 })
|
||||
} finally {
|
||||
await page.evaluate(() => { document.dispatchEvent(new Event('dsh-test-return-stop')) })
|
||||
await trace
|
||||
await page.evaluate(() => { delete document.documentElement.dataset.returnProbe })
|
||||
}
|
||||
return trace
|
||||
}
|
||||
const gates = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
|
||||
const held = [false, false]
|
||||
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
let index = -1
|
||||
for await (const chunk of next()) {
|
||||
if (chunk.type === 'text-delta') {
|
||||
const gate = gates[index++]
|
||||
if (gate !== undefined) {
|
||||
held[index - 1] = true
|
||||
await gate.promise
|
||||
}
|
||||
}
|
||||
yield chunk
|
||||
}
|
||||
})
|
||||
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
||||
await page.context().tracing.start({ screenshots: true, snapshots: true })
|
||||
try {
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_RUNNING_RETURN_NEXT_INPUT')
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => held[0]).toBe(true)
|
||||
await page.getByText(heldMarker, { exact: true }).waitFor()
|
||||
await page.locator('[data-chat-running]').waitFor()
|
||||
await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor()
|
||||
await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0)
|
||||
await expectBottom(page)
|
||||
const before = await scrollGeometry(page)
|
||||
const chunkCount = () => world.assistantFrames.filter(frame => frame.type === 'chunk').length
|
||||
const heldChunks = chunkCount()
|
||||
const running = await sampleReturn(heldMarker)
|
||||
const chunksAfterReturn = chunkCount()
|
||||
await writeFile(join(evidence, 'running-frames.json'), JSON.stringify({ before, heldChunks, chunksAfterReturn, samples: running }, null, 2))
|
||||
await page.screenshot({ path: join(evidence, 'running-held.png') })
|
||||
expect(chunksAfterReturn).toBe(heldChunks)
|
||||
expect(world.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor()
|
||||
|
||||
const beforeGrowth = await scrollGeometry(page)
|
||||
gates[0]!.resolve(undefined)
|
||||
await expect.poll(() => held[1]).toBe(true)
|
||||
await page.getByText(grownMarker, { exact: true }).waitFor()
|
||||
await expectBottom(page)
|
||||
const afterGrowth = await scrollGeometry(page)
|
||||
expect(afterGrowth.scrollTop - beforeGrowth.scrollTop).toBeGreaterThan(200)
|
||||
expect(await page.locator('[data-chat-following-tail]').count()).toBe(1)
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await expectMarkerAboveComposer(page, grownMarker)
|
||||
settled = world.scaffold.whenTurnSettled()
|
||||
gates[1]!.resolve(undefined)
|
||||
await settled
|
||||
await page.getByText(doneMarker, { exact: true }).waitFor()
|
||||
await expect.poll(() => page.locator('[data-chat-running], [data-streaming="true"]').count()).toBe(0)
|
||||
await expectBottom(page)
|
||||
const completed = await sampleReturn(doneMarker)
|
||||
await page.screenshot({ path: join(evidence, 'completed.png') })
|
||||
const summarize = (samples: ReturnFrame[]) => {
|
||||
const visible = samples.filter(sample => sample.sessionA)
|
||||
const first = visible[0]!
|
||||
const markerMovement = Math.max(...visible.map(sample =>
|
||||
sample.markerY === null || first.markerY === null ? Infinity : Math.abs(sample.markerY - first.markerY)))
|
||||
return { frames: visible.length, markerMovement, first, last: visible.at(-1)! }
|
||||
}
|
||||
const summary = { running: summarize(running), completed: summarize(completed) }
|
||||
await writeFile(join(evidence, 'return-trace.json'), JSON.stringify({
|
||||
viewport: { width: 1280, height: 720 }, mode: 'standard', collapseTiming: 'next-input',
|
||||
historyTurns: { a: fixtureA.turns, b: fixtureB.turns }, initialTextLength: parts[0]!.length,
|
||||
before, heldChunks, chunksAfterReturn, beforeGrowth, afterGrowth, summary, running, completed,
|
||||
}, null, 2))
|
||||
console.log(`running-return e2e: ${JSON.stringify(summary)}; evidence=${evidence}`)
|
||||
for (const [name, samples] of [['completed', completed], ['running', running]] as const) {
|
||||
const visible = samples.filter(sample => sample.sessionA)
|
||||
expect(visible.length, `${name} return did not capture its first 60 visible frames`).toBe(60)
|
||||
expect(visible.every(sample => sample.markerY !== null), `${name} return omitted the response in a visible frame`).toBe(true)
|
||||
expect(visible.every(sample => sample.running === (name === 'running'))).toBe(true)
|
||||
expect(new Set(visible.map(sample => sample.responseLength)).size, `${name} response changed while output was held`).toBe(1)
|
||||
expect(summary[name].markerMovement, `${name} Session moved after its first visible frame; trace=${evidence}`)
|
||||
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
await expectBottom(page)
|
||||
assertClean(world)
|
||||
} finally {
|
||||
settled ??= world.events.some(event => event.type === 'turn/start')
|
||||
&& !world.events.some(event => event.type === 'turn/end') ? world.scaffold.whenTurnSettled() : undefined
|
||||
for (const gate of gates) gate.resolve(undefined)
|
||||
dispose()
|
||||
try {
|
||||
await settled
|
||||
} finally {
|
||||
await page.context().tracing.stop({ path: join(evidence, 'browser-trace.zip') })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-floating-controls',
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 8b2586e6b9f188a7
|
||||
zh: 6687f9e67432d04f
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 838659819e09413d
|
||||
zh: 43c754f34c193fff
|
||||
en: 5bcd9284adc0e0a7
|
||||
zh: cf633bb13e2c63b2
|
||||
/deepseek-ai-dsh-client-ui-chat/model-experience:
|
||||
en: c17b4dd43a61c569
|
||||
zh: f7414309810c3a08
|
||||
|
||||
@@ -120,6 +120,8 @@ Process rows share one text and icon color in every Work details mode: tertiary
|
||||
<a id="scroll-ownership"></a>
|
||||
## Scroll ownership
|
||||
|
||||
Chat waits for initial history before automatic content, scroll, or size updates initialize reading state. Running Sessions restore their initial visibility and position before fold animations become eligible.
|
||||
|
||||
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the floor within subpixel rounding, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Under completion-time folding, submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
|
||||
|
||||
The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion.
|
||||
@@ -130,6 +132,8 @@ Outer transcript following and each open capped group's following are independen
|
||||
|
||||
Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes.
|
||||
|
||||
Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow.
|
||||
|
||||
Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node and image rendering, and shares the existing Chat store and Session node sources. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView.
|
||||
|
||||
| Outer follows | Open group follows | Back-to-bottom button | New content |
|
||||
|
||||
@@ -120,6 +120,8 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure`
|
||||
<a id="scroll-ownership"></a>
|
||||
## 滚动归属
|
||||
|
||||
Chat 等待初始历史就绪后,才允许自动内容、滚动或尺寸更新初始化阅读状态。进行中的 Session 先恢复初始显隐和位置,再允许折叠动画。
|
||||
|
||||
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及到达底部(容许小于 1px 的取整误差)的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。采用完成时收起时,提交正文输入或 steering 会立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
|
||||
|
||||
轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。
|
||||
@@ -130,6 +132,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。
|
||||
|
||||
切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。
|
||||
|
||||
折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点和图片渲染,并共享既有 Chat store 与 Session 节点来源。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。
|
||||
|
||||
| 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 |
|
||||
|
||||
@@ -68,6 +68,7 @@ export function ChatView({
|
||||
?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order])
|
||||
const nodeStore = useChat(s => s.nodes)
|
||||
const deferCompletedTurns = usePresentation(policy => policy.collapseTiming === 'next-input')
|
||||
const transcriptView = usePresentation(policy => policy.mode)
|
||||
// The rail's items are accumulated in the Chat snapshot, so this selector is
|
||||
// both the data and its change signal: the array identity moves only when a
|
||||
// Turn enters, leaves, or changes its preview.
|
||||
@@ -189,6 +190,7 @@ export function ChatView({
|
||||
steeringId,
|
||||
submissionId: visibleSubmissions.at(-1)?.requestId ?? null,
|
||||
loadedTurns: turnNavigationItems,
|
||||
transcriptView,
|
||||
})
|
||||
const flowContext = useMemo<ChatFlowHookContext>(() => ({ motion: scroll.motion }), [scroll.motion])
|
||||
|
||||
@@ -231,7 +233,7 @@ export function ChatView({
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
</div>
|
||||
{/* Room added when rows above the reader close, given back only as content grows (use-chat-viewport). */}
|
||||
{/* Fold room is reclaimed by viewport scroll, content-growth, and display-mode policies. */}
|
||||
<div className={css.turnSpacer} data-chat-turn-spacer aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Composes viewport operations, reading policy, and history navigation for Chat. */
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, type RefObject } from 'react'
|
||||
import type { TranscriptViewMode } from '../../chat-settings.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { useChatNavigation, type ChatNavigation, type ChatNavigationInput } from './use-chat-navigation.ts'
|
||||
@@ -19,6 +20,8 @@ export interface ChatScrollInput extends ChatNavigationInput {
|
||||
readonly running: boolean
|
||||
/** Enable the browser-local delayed-fold experiment and its input reveal animation. */
|
||||
readonly deferCompletedTurns: boolean
|
||||
/** A changed work-details preference resets fold reservations after the layout settles. */
|
||||
readonly transcriptView: TranscriptViewMode
|
||||
readonly loadedTurns: ReturnType<ChatSnapshot['navigation']['items']>
|
||||
}
|
||||
|
||||
@@ -33,7 +36,8 @@ interface ChatScrollState extends ChatReadingState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate scroll policy after Chat content commits.
|
||||
* Coordinate scroll policy after Chat content commits. Initial history must be ready
|
||||
* before automatic scroll or resize handling can initialize reading state and fold motion.
|
||||
* New submitted input supersedes pending reader sampling.
|
||||
* @param input - current Chat content, scroll memory, and history operations.
|
||||
* @returns element refs, visible reading state, and navigation callbacks.
|
||||
@@ -41,7 +45,7 @@ interface ChatScrollState extends ChatReadingState {
|
||||
export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const {
|
||||
ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, running, deferCompletedTurns,
|
||||
loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough,
|
||||
loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, transcriptView,
|
||||
} = input
|
||||
const { viewport, listRef, columnRef } = useChatViewport()
|
||||
const { reading, state } = useChatReading(viewport, chatScroll, loadedTurns.at(-1)?.turn ?? null)
|
||||
@@ -78,6 +82,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
|
||||
const processContent = useCallback(() => {
|
||||
const current = content.current.input
|
||||
if (!current.ready && !content.current.opened) return
|
||||
const previous = content.current.applied
|
||||
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey)
|
||||
|| (current.steeringId !== null && current.steeringId !== previous?.steeringId
|
||||
@@ -117,6 +122,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
useLayoutEffect(() => {
|
||||
const disconnectViewport = viewport.connect({
|
||||
scroll: (event) => {
|
||||
if (!content.current.opened) return
|
||||
if (!event.movedByReader && viewport.motion.foldActive()) return
|
||||
reading.onScroll(event)
|
||||
},
|
||||
@@ -127,6 +133,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
interact: () => { navigation.cancel() },
|
||||
intent: () => { cancelPendingFollow(); reading.interruptFollow() },
|
||||
resize: () => {
|
||||
if (!content.current.opened) return
|
||||
if (navigation.contentCommitted()) {
|
||||
navigation.reconcile()
|
||||
return
|
||||
@@ -154,7 +161,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const previous = content.current.input
|
||||
content.current.input = {
|
||||
ready, order, lastKey, lastIsUser, steeringId, submissionId, running, loadedTurns, chatScroll,
|
||||
deferCompletedTurns, ...navigationInput,
|
||||
deferCompletedTurns, transcriptView, ...navigationInput,
|
||||
}
|
||||
if (!deferCompletedTurns) {
|
||||
cancelPendingFollow()
|
||||
@@ -165,12 +172,24 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
if (layoutChanged) viewport.invalidate()
|
||||
viewport.reclaimBelow()
|
||||
processContent()
|
||||
if (layoutChanged) reading.refreshActiveTurn()
|
||||
if (layoutChanged && content.current.opened) reading.refreshActiveTurn()
|
||||
}, [
|
||||
viewport, reading, processContent, navigationInput, ready, order, lastKey, lastIsUser,
|
||||
steeringId, submissionId, running, loadedTurns, chatScroll, deferCompletedTurns, cancelPendingFollow,
|
||||
steeringId, submissionId, running, loadedTurns, chatScroll, deferCompletedTurns, transcriptView, cancelPendingFollow,
|
||||
])
|
||||
|
||||
const previousView = useRef(transcriptView)
|
||||
useLayoutEffect(() => {
|
||||
if (previousView.current === transcriptView) return
|
||||
previousView.current = transcriptView
|
||||
cancelPendingFollow()
|
||||
reading.interruptFollow()
|
||||
return viewport.motion.onFoldIdle(() => {
|
||||
viewport.resetBelow()
|
||||
if (content.current.opened) reading.onResize()
|
||||
})
|
||||
}, [transcriptView, viewport, reading, cancelPendingFollow])
|
||||
|
||||
const returnToBottom = useCallback(() => {
|
||||
cancelPendingFollow()
|
||||
navigation.cancel()
|
||||
|
||||
@@ -160,6 +160,12 @@ export class ChatViewport {
|
||||
this.setSpacer(this.spacerHeight() + px)
|
||||
}
|
||||
|
||||
/** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */
|
||||
resetBelow(): void {
|
||||
this.setSpacer(0)
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Give reserved room back after acknowledged scrolling or content growth without clamping the reader.
|
||||
* Holds still during folds and unsampled movement; following includes the room that remains.
|
||||
|
||||
@@ -11,7 +11,7 @@ export type CollapseTiming = typeof COLLAPSE_TIMINGS[number]
|
||||
|
||||
/** Presentation capabilities that one work-details mode enables. */
|
||||
export interface ChatPresentationPolicy {
|
||||
/** Mode this policy was derived from; for diagnostics, never for branching in renderers. */
|
||||
/** Preference identity for layout resets and diagnostics; renderers select the capabilities below. */
|
||||
readonly mode: TranscriptViewMode
|
||||
/** Browser-local experiment; completion retains the default immediate folding and scrolling. */
|
||||
readonly collapseTiming: CollapseTiming
|
||||
|
||||
@@ -26,7 +26,7 @@ function ScrollHarness({ input, onMotion }: {
|
||||
const state = useChatScroll(input)
|
||||
useLayoutEffect(() => { onMotion(state.motion) }, [onMotion, state.motion])
|
||||
return <>
|
||||
<div ref={state.listRef} data-testid="scrollport">
|
||||
<div ref={state.listRef} data-testid="scrollport" data-initialized={state.initialized}>
|
||||
<div ref={state.columnRef} data-testid="column" data-chat-flow="" />
|
||||
<div data-chat-turn-spacer="" />
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ function ScrollHarness({ input, onMotion }: {
|
||||
</>
|
||||
}
|
||||
|
||||
function mountScroll(deferCompletedTurns = true) {
|
||||
function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInput> = {}) {
|
||||
let input: ChatScrollInput = {
|
||||
ready: true,
|
||||
order: [],
|
||||
@@ -45,12 +45,14 @@ function mountScroll(deferCompletedTurns = true) {
|
||||
submissionId: null,
|
||||
running: false,
|
||||
deferCompletedTurns,
|
||||
transcriptView: 'verbose',
|
||||
loadedTurns: [],
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
loadOlder: vi.fn(),
|
||||
loadThrough: vi.fn(async () => {}),
|
||||
chatScroll: { read: () => null, save: vi.fn() },
|
||||
...initial,
|
||||
}
|
||||
const callbacks: { motion?: FlowMotionRows; resize?: () => void } = {}
|
||||
class Observer implements ResizeObserver {
|
||||
@@ -99,6 +101,29 @@ function mountScroll(deferCompletedTurns = true) {
|
||||
}
|
||||
|
||||
describe('Chat scroll collapse timing', () => {
|
||||
it.each(['resize', 'scroll', 'history', 'input', 'preference'] as const)(
|
||||
'waits for initial history before initializing a running view after %s', (change) => {
|
||||
const h = mountScroll(true, { ready: false, running: true })
|
||||
expect(h.scroller.dataset.initialized).toBe('false')
|
||||
if (change === 'resize') h.resize()
|
||||
else if (change === 'scroll') fireEvent.scroll(h.scroller)
|
||||
else if (change === 'history') h.update({ order: ['history'], lastKey: 'history' })
|
||||
else if (change === 'input') h.update({ submissionId: 'pending-input' })
|
||||
else h.update({ transcriptView: 'standard' })
|
||||
expect(h.scroller.dataset.initialized).toBe('false')
|
||||
expect(h.scroller.scrollTop).toBe(0)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
h.update({ ready: true })
|
||||
expect(h.scroller.dataset.initialized).toBe('true')
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
h.grow(1_200)
|
||||
h.resize()
|
||||
expect(h.scroller.scrollTop).toBe(800)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not defer one viewport\'s submitted input behind another viewport\'s fold', () => {
|
||||
const first = mountScroll()
|
||||
const second = mountScroll()
|
||||
@@ -156,6 +181,56 @@ describe('Chat scroll collapse timing', () => {
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height).toBe('20px')
|
||||
})
|
||||
|
||||
it.each([false, true])('clears mode-change bottom space after the fold (reader interrupts=%s)', async (interrupt) => {
|
||||
const h = mountScroll()
|
||||
h.resize()
|
||||
h.startFold()
|
||||
h.update({ transcriptView: 'standard' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const spacer = h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
|
||||
expect(spacer.style.height).toBe('80px')
|
||||
h.grow(920)
|
||||
if (interrupt) {
|
||||
fireEvent.wheel(h.scroller, { deltaY: -100 })
|
||||
h.scroller.scrollTop = 400
|
||||
fireEvent.scroll(h.scroller)
|
||||
}
|
||||
h.finishFold()
|
||||
expect(spacer.style.height).toBe('')
|
||||
expect(h.scroller.scrollHeight).toBe(920)
|
||||
expect(h.scroller.scrollTop).toBe(interrupt ? 400 : 520)
|
||||
expect(h.row.hidden).toBe(true)
|
||||
h.resize()
|
||||
expect(spacer.style.height).toBe('')
|
||||
})
|
||||
|
||||
it('clears a previous fold reservation on an idle mode change', async () => {
|
||||
const h = mountScroll()
|
||||
h.resize()
|
||||
h.startFold()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
h.grow(920)
|
||||
h.finishFold()
|
||||
h.resize()
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.style.height).toBe('80px')
|
||||
h.update({ transcriptView: 'standard' })
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.style.height).toBe('')
|
||||
expect(h.scroller.scrollTop).toBe(520)
|
||||
})
|
||||
|
||||
it('keeps the latest mode reset when preferences change before a fold finishes', async () => {
|
||||
const h = mountScroll()
|
||||
h.resize()
|
||||
h.startFold()
|
||||
h.update({ transcriptView: 'standard' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
h.update({ transcriptView: 'detailed' })
|
||||
h.grow(920)
|
||||
h.finishFold()
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.style.height).toBe('')
|
||||
expect(h.scroller.scrollTop).toBe(520)
|
||||
})
|
||||
|
||||
it('follows only the distance left after a smaller fold', async () => {
|
||||
const h = mountScroll()
|
||||
h.scroller.scrollTop = 600
|
||||
|
||||
Reference in new issue
Block a user