فهرست منبع

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.
Yichen Jiang 1 ماه پیش
والد
کامیت
3a834fe6c9

+ 57 - 0
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',

+ 32 - 8
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -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

+ 4 - 1
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')
   })