feat(ui-chat): settle jump landings after paging completes

A mid-paging landing keeps the jump armed with the target row as its
paging anchor, so later chunks and the load-earlier button's unmount
cannot drift the landing; the loader's completion runs one final
correction unless the reader already scrolled off the target. Adds the
browser contract: full outline ladder, keyboard jump on an unloaded
mark, landing geometry, and rail fades.
This commit is contained in:
Yichen Jiang 2026-08-30 13:18:03 +08:00 • committed by imccyu
parent 6af1ee49b1
commit 3a834fe6c9
3 changed files with 93 additions and 9 deletions

View file

@ -42,6 +42,7 @@ const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
const RAIL_SESSION_ID = 'chat-scroll-rail-e2e'
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
@ -557,6 +558,62 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => {
await withScrollWorld({
failureShot: 'web-e2e-turn-rail-jump',
seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
}, async (world) => {
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
await expectBottom(world.page)
// The whole-log outline reaches the rail before any paging: one mark
// per fixture turn, the oldest still in its load-and-jump form.
const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 })
.toBe(HISTORY_FIXTURE.turns)
const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
expect(await firstUnloaded.count()).toBe(1)
// Fixed pitch: the ladder keeps its natural height, scrolls inside the
// frame, and (following the active tail mark) fades its upper end.
expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height')))
.toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`)
const railScroller = rail.locator('[class*="scroller"]')
await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
// Activate the unloaded mark by keyboard: pointer input belongs to the
// rail frame, while each mark is the keyboard/AT destination.
const beforeRows = await loadedFlowRows(world.page)
await firstUnloaded.focus()
await world.page.keyboard.press('Enter')
// The jump pages history in and lands on turn 1: its mark flips to the
// loaded label and becomes current, the window grew, and the turn-1
// user row sits at the reading line.
const firstLoaded = rail.getByRole('button', { name: 'Jump to turn 1', exact: true })
await expect.poll(() => firstLoaded.count(), { timeout: 60_000 }).toBe(1)
await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
expect(await loadedFlowRows(world.page)).toBeGreaterThan(beforeRows)
// Drop mark focus so its hover/focus preview (which echoes the prompt
// marker) leaves the DOM before the transcript count below.
await firstLoaded.evaluate((el) => { (el as HTMLElement).blur() })
await expect.poll(() => world.page.getByRole('tooltip').count(), { timeout: 15_000 }).toBe(0)
await nextPaint(world.page)
const marker = world.page.locator('[data-conversation-scroll]')
.getByText(HISTORY_FIXTURE.markers.user(1), { exact: false })
expect(await marker.count()).toBe(1)
const scrollport = await world.page.locator('[data-conversation-scroll]').boundingBox()
const row = await marker.boundingBox()
if (scrollport === null || row === null) throw new Error('turn-1 row or scrollport has no layout box')
expect(row.y - scrollport.y).toBeGreaterThanOrEqual(0)
expect(row.y - scrollport.y).toBeLessThanOrEqual(160)
// The rail followed the landing to the ladder top, so the fade now
// marks the other (downward) end.
await expect.poll(() => rail.locator('[class*="fadeBottom"]').count(), { timeout: 15_000 }).toBe(1)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-live-tool',

View file

@ -305,6 +305,8 @@ export function ChatView({
const anchorRef = useRef<PagingAnchor | null>(null)
/** Unloaded-turn jump in flight: target turn plus its load-through seq. */
const pendingJumpRef = useRef<{ turn: number; seq: number } | null>(null)
/** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */
const jumpLandedRef = useRef(false)
const [busyJumpTurn, setBusyJumpTurn] = useState<number | null>(null)
/** Bumped when a loadThrough completion settles, after its last page's commit. */
const [jumpSettleTick, setJumpSettleTick] = useState(0)
@ -412,18 +414,37 @@ export function ChatView({
else if (position !== null) chatScroll.save(position)
}
/** Land the pending jump once its Turn has a rendered anchor row; false while it must keep waiting. */
const realizePendingJump = (local: HTMLElement, el: HTMLElement): boolean => {
/**
* Land the pending jump once its Turn has a rendered anchor row; false
* while it must keep waiting. Mid-jump landings (`settle` false) keep the
* jump armed with the target row as the paging anchor, so later chunks and
* the load-earlier button's unmount re-land on the same row; the settling
* call clears the jump.
*/
const realizePendingJump = (local: HTMLElement, el: HTMLElement, settle: boolean): boolean => {
const pending = pendingJumpRef.current
if (pending === null) return true
const item = railItems.find(candidate => candidate.turn === pending.turn)
if (item === undefined || item.anchor.kind !== 'loaded') return false
const row = anchorElement(local, item.anchor.key)
if (row === null) return false
pendingJumpRef.current = null
setBusyJumpTurn(null)
anchorRef.current = null
if (settle) {
pendingJumpRef.current = null
setBusyJumpTurn(null)
const held = anchorRef.current
const landedEarlier = jumpLandedRef.current
jumpLandedRef.current = false
anchorRef.current = null
// A reader who moved off an already-landed target mid-jump keeps their
// place; a first landing, or an untouched one, takes the correction.
if (!landedEarlier || held?.key === item.anchor.key) {
landOnRowRef.current(local, el, row, pending.turn)
}
return true
}
landOnRowRef.current(local, el, row, pending.turn)
jumpLandedRef.current = true
anchorRef.current = { key: item.anchor.key, top: flowTop(row, el) }
return true
}
@ -470,7 +491,7 @@ export function ChatView({
observedTopRef.current = el.scrollTop
// A jump chunk lands here: scroll to the target once its rows exist;
// until then keep holding the reader's row for the next chunk.
if (!realizePendingJump(local, el) && row !== null) {
if (!realizePendingJump(local, el, false) && row !== null) {
anchorRef.current = { key: anchor.key, top: flowTop(row, el) }
}
firstSeqRef.current = firstSeq
@ -500,7 +521,7 @@ export function ChatView({
}
// A jump whose target committed outside the anchored-prepend path (for
// example after a mid-jump toBottom dropped the held anchor) lands here.
if (pendingJumpRef.current !== null) realizePendingJump(local, el)
if (pendingJumpRef.current !== null) realizePendingJump(local, el, false)
})
const onScrollRef = useRef(() => {})
@ -604,7 +625,9 @@ export function ChatView({
const local = listRef.current
if (pending === null || local === null) return
const el = scrollerOf(local)
if (realizePendingJump(local, el)) return
// The settling landing runs after the load-earlier button's unmount
// commit, so the target row cannot drift once the jump clears.
if (realizePendingJump(local, el, true)) return
const uncovered = firstSeq === null || firstSeq > pending.seq
if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) {
jumpRepageHeadRef.current = firstSeq
@ -656,6 +679,7 @@ export function ChatView({
}
pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq }
jumpRepageHeadRef.current = null
jumpLandedRef.current = false
setBusyJumpTurn(item.turn)
void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
return

View file

@ -694,8 +694,11 @@ describe('ChatView', () => {
})
const first = view.getByRole('button', { name: '跳转到第 1 轮' })
expect(first.getAttribute('aria-current')).toBe('true')
expect(first.getAttribute('aria-busy')).toBeNull()
// The mark stays busy until the jump settles: the loader's completion
// runs the final landing correction after the load-earlier button leaves.
expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
expect(first.getAttribute('aria-busy')).toBeNull()
expect(first.getAttribute('aria-current')).toBe('true')
})