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:
parent
6af1ee49b1
commit
3a834fe6c9
3 changed files with 93 additions and 9 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue