From 3a834fe6c952ce3eb80509cab6a7396164e8a193 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 13:18:03 +0800 Subject: [PATCH] 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. --- apps/web/tests/chat-scroll-contract.e2e.ts | 57 +++++++++++++++++++ .../ui-chat/src/client/chat/ChatView.tsx | 40 ++++++++++--- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 2664ef0f01..3058f669c5 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -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', diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 2c41dbd057..71cd1755de 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -305,6 +305,8 @@ export function ChatView({ const anchorRef = useRef(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(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 diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d47c4519d6..87231f9724 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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') })