فهرست منبع

fix(web): preserve chat scroll ownership

kingwl 2 ماه پیش
والد
کامیت
8de33e42f7

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
-2026-07-29-sticky-composer-conversation-scroll.md: 1ec68cfbf2d78b2bda936822be6784f6fbd17a20
-2026-07-29-sticky-composer-conversation-scroll.zh.md: b163b3b08ce9664c7d0aad6b46fca1675f29ce1f
+2026-07-29-sticky-composer-conversation-scroll.md: f7cc843f322967ac6a66457aee237bc74174263d
+2026-07-29-sticky-composer-conversation-scroll.zh.md: 22d59294e064beacf86680c4b45355a51f47213e

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md


+ 18 - 2
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -290,6 +290,23 @@ async function wheelUntilMounted(page: Page, selector: string, deltaY: number):
   throw new Error(`selector did not mount during transcript wheel: ${selector}`)
 }
 
+async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
+  const target = page.locator(selector)
+  for (let attempt = 0; attempt < 32; attempt += 1) {
+    if (await target.count() > 0 && await target.evaluate((row) => {
+      const host = row.closest<HTMLElement>('[data-conversation-scroll]')
+      if (host === null) return false
+      const viewport = host.getBoundingClientRect()
+      const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
+      const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
+      const rect = row.getBoundingClientRect()
+      return rect.bottom > viewport.top && rect.top < visibleBottom
+    })) return
+    await wheelTranscript(page, deltaY)
+  }
+  throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
+}
+
 function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
   return page.locator('[data-conversation-scroll]').evaluate((host) => {
     const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
@@ -542,8 +559,7 @@ describe('web e2e: long Chat scroll contract', () => {
 
       const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash-global"]`
       const liveRow = world.page.locator(liveRowSelector)
-      await liveRow.scrollIntoViewIfNeeded()
-      await nextPaint(world.page)
+      await wheelUntilVisible(world.page, liveRowSelector, -300)
       const toolAnchor = await liveRow.evaluate((row) => {
         const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
         const host = row.closest<HTMLElement>('[data-conversation-scroll]')

+ 399 - 39
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -38,6 +38,68 @@ import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
 import css from './ChatView.module.css'
 
 const FOLLOW_THRESHOLD = 24
+const POINTER_CANCEL_GRACE_FRAMES = 8
+
+type VerticalDirection = -1 | 0 | 1
+
+interface ReaderGesture {
+  /** Reader-created distance from the followed floor, excluding layout growth. */
+  awayDistance: number
+  /** Incremented by each qualifying input or host movement. */
+  epoch: number
+  /** Pointer gestures remain live through their pressed phase. */
+  held: boolean
+  /** Latest host position, used to reject no-op input events. */
+  lastTop: number
+  /** Primary pointer that owns a held direct-manipulation gesture. */
+  pointerId: number | null
+}
+
+/** Whether this element can consume a vertical gesture in the given direction. */
+function canScrollVertically(element: HTMLElement, direction: VerticalDirection): boolean {
+  if (direction === 0) return element.scrollHeight > element.clientHeight + 1
+  if (direction < 0) return element.scrollTop > 1
+  return element.scrollTop + element.clientHeight < element.scrollHeight - 1
+}
+
+/** A nested overflow owner keeps wheel/key input away from the transcript. */
+function nestedOwnsVerticalScroll(
+  target: EventTarget | null,
+  scrollport: HTMLElement,
+  direction: VerticalDirection,
+): boolean {
+  let current = target instanceof HTMLElement ? target : null
+  while (current !== null && current !== scrollport) {
+    const style = getComputedStyle(current)
+    const scrollable = style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflowY === 'overlay'
+    if (scrollable && current.scrollHeight > current.clientHeight + 1) {
+      if (canScrollVertically(current, direction)) return true
+      if (style.overscrollBehaviorY === 'contain' || style.overscrollBehaviorY === 'none') return true
+    }
+    current = current.parentElement
+  }
+  return false
+}
+
+/** Vertical scrolling keys and their expected host direction. */
+function keyDirection(event: globalThis.KeyboardEvent): VerticalDirection | null {
+  if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') return -1
+  if (event.key === 'ArrowDown' || event.key === 'PageDown' || event.key === 'End') return 1
+  if (event.key === ' ' || event.key === 'Spacebar') return event.shiftKey ? -1 : 1
+  return null
+}
+
+/** Whether the focused control, rather than its scroll ancestor, owns this key. */
+function consumesScrollKey(event: globalThis.KeyboardEvent): boolean {
+  const target = event.target
+  if (!(target instanceof HTMLElement)) return false
+  const editable = target.isContentEditable || target.closest('input, textarea, select') !== null
+  if (event.key === ' ' || event.key === 'Spacebar') {
+    return editable || target.closest('button, [role="button"], [role="menuitem"]') !== null
+  }
+  if (event.key === 'PageUp' || event.key === 'PageDown') return false
+  return editable || target.closest('[role="tab"], [role="menuitem"], [role="listbox"], [role="option"], [role="slider"], [role="spinbutton"]') !== null
+}
 
 /** Active column host when present; otherwise the view-local scroller. */
 function scrollerOf(from: HTMLElement): HTMLElement {
@@ -335,8 +397,19 @@ export function ChatView({
 
   const listRef = useRef<HTMLDivElement | null>(null)
   const columnRef = useRef<HTMLDivElement | null>(null)
-  const atBottomRef = useRef(true)
-  const [atBottom, setAtBottom] = useState(true)
+  /** Reader-owned follow intent, distinct from transient bottom geometry. */
+  const bottomOwnedRef = useRef(true)
+  const [bottomOwned, setBottomOwned] = useState(true)
+  /** Distance created by reader movement while follow ownership is retained. */
+  const readerAwayRef = useRef(0)
+  /** Last scrollTop delivered or written on the main thread. */
+  const observedTopRef = useRef(0)
+  /** Only an observable reader input may let scroll geometry change follow intent. */
+  const readerGestureRef = useRef<ReaderGesture | null>(null)
+  const finishReaderGestureRef = useRef<(gesture: ReaderGesture) => void>(() => {})
+  const scheduleReaderGestureEndRef = useRef<(gesture: ReaderGesture) => void>(() => {})
+  const schedulePointerCancelEndRef = useRef<(gesture: ReaderGesture) => void>(() => {})
+  const followRef = useRef<(() => void) | null>(null)
   /** Paging anchor: semantic row/position at click, updated by reader scrolls
    * while the request is pending and restored after the prepend lands. */
   const anchorRef = useRef<PagingAnchor | null>(null)
@@ -355,14 +428,117 @@ export function ChatView({
   const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
   const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
 
-  const toBottom = (el: HTMLElement): void => {
-    anchorRef.current = null
+  const writeBottom = (el: HTMLElement): void => {
     el.scrollTop = el.scrollHeight
-    atBottomRef.current = true
-    setAtBottom(true)
+    observedTopRef.current = el.scrollTop
+    readerAwayRef.current = 0
     chatScroll.save(null)
   }
 
+  const cancelReaderGesture = (): void => {
+    readerGestureRef.current = null
+  }
+
+  const toBottom = (el: HTMLElement): void => {
+    cancelReaderGesture()
+    anchorRef.current = null
+    bottomOwnedRef.current = true
+    setBottomOwned(true)
+    writeBottom(el)
+  }
+
+  const recordReaderPosition = (local: HTMLElement, el: HTMLElement, ownsBottom?: boolean): boolean => {
+    const nextOwnsBottom = ownsBottom
+      ?? el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+    bottomOwnedRef.current = nextOwnsBottom
+    setBottomOwned(nextOwnsBottom)
+    const position = nextOwnsBottom ? null : scrollPosition(local, el)
+    if (nextOwnsBottom) {
+      anchorRef.current = null
+      chatScroll.save(null)
+    } else {
+      if (anchorRef.current !== null && position !== null) {
+        anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
+      }
+      if (position !== null) chatScroll.save(position)
+    }
+    return nextOwnsBottom
+  }
+
+  finishReaderGestureRef.current = (gesture) => {
+    if (readerGestureRef.current !== gesture) return
+    readerGestureRef.current = null
+    const local = listRef.current
+    if (local === null) return
+    const el = scrollerOf(local)
+    const physicalAway = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight)
+    if (bottomOwnedRef.current && Math.abs(physicalAway - readerAwayRef.current) > 1) {
+      followRef.current?.()
+    }
+  }
+
+  // A wheel tick, smooth keyboard scroll, or touch fling can span multiple
+  // scroll events. End only after two animation frames without new movement;
+  // a held pointer keeps the gesture alive across slower frames.
+  scheduleReaderGestureEndRef.current = (gesture) => {
+    const epoch = gesture.epoch
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        if (readerGestureRef.current === gesture && gesture.epoch === epoch && !gesture.held) {
+          finishReaderGestureRef.current(gesture)
+        }
+      })
+    })
+  }
+
+  schedulePointerCancelEndRef.current = (gesture) => {
+    const epoch = gesture.epoch
+    let frames = POINTER_CANCEL_GRACE_FRAMES
+    const waitForScroll = (): void => {
+      requestAnimationFrame(() => {
+        if (readerGestureRef.current !== gesture || gesture.epoch !== epoch) return
+        frames -= 1
+        if (frames > 0) waitForScroll()
+        else finishReaderGestureRef.current(gesture)
+      })
+    }
+    waitForScroll()
+  }
+
+  const armReaderGesture = (
+    startTop: number,
+    startBottom: number,
+    held = false,
+    pointerId: number | null = null,
+  ): ReaderGesture => {
+    const current = readerGestureRef.current
+    const persistentAway = bottomOwnedRef.current
+      ? readerAwayRef.current
+      : Math.max(0, startBottom - startTop)
+    const gesture = current ?? {
+      awayDistance: persistentAway,
+      epoch: 0,
+      held: false,
+      lastTop: startTop,
+      pointerId: null,
+    }
+    gesture.epoch += 1
+    if (held) {
+      gesture.held = true
+      gesture.pointerId = pointerId
+    }
+    readerGestureRef.current = gesture
+    scheduleReaderGestureEndRef.current(gesture)
+    return gesture
+  }
+
+  followRef.current = () => {
+    const local = listRef.current
+    if (local !== null && bottomOwnedRef.current && readerGestureRef.current === null) {
+      writeBottom(scrollerOf(local))
+    }
+  }
+
   useLayoutEffect(() => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
@@ -377,15 +553,21 @@ export function ChatView({
       if (saved === null) {
         toBottom(el)
       } else {
+        cancelReaderGesture()
         el.scrollTop = saved.scrollTop
         const row = anchorElement(local, saved.anchorKey)
         if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
         const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
-        atBottomRef.current = isAtBottom
-        setAtBottom(isAtBottom)
-        const normalized = isAtBottom ? null : scrollPosition(local, el)
-        if (isAtBottom) chatScroll.save(null)
-        else if (normalized !== null) chatScroll.save(normalized)
+        if (isAtBottom) {
+          toBottom(el)
+        } else {
+          observedTopRef.current = el.scrollTop
+          readerAwayRef.current = 0
+          bottomOwnedRef.current = false
+          setBottomOwned(false)
+          const normalized = scrollPosition(local, el)
+          if (normalized !== null) chatScroll.save(normalized)
+        }
       }
       firstSeqRef.current = firstSeq
       lastKeyRef.current = lastKey
@@ -400,7 +582,16 @@ export function ChatView({
       const anchor = anchorRef.current
       anchorRef.current = null
       const row = anchorElement(local, anchor.key)
+      cancelReaderGesture()
       if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
+      observedTopRef.current = el.scrollTop
+      const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+      readerAwayRef.current = 0
+      bottomOwnedRef.current = isAtBottom
+      setBottomOwned(isAtBottom)
+      const normalized = isAtBottom ? null : scrollPosition(local, el)
+      if (isAtBottom) chatScroll.save(null)
+      else if (normalized !== null) chatScroll.save(normalized)
       firstSeqRef.current = firstSeq
       /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
       lastKeyRef.current = lastKey
@@ -419,8 +610,9 @@ export function ChatView({
     lastSteeringIdRef.current = lastSteeringId
     followSigRef.current = followSig
     // Follow new flow content while pinned; do NOT re-pin on every render
-    // merely because atBottomRef is true (scroll threshold → setState → snap).
-    if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
+    // merely because bottomOwnedRef is true (scroll threshold → setState → snap).
+    if (appendedUser || appendedSteering
+      || (tipMoved && bottomOwnedRef.current && readerGestureRef.current === null)) toBottom(el)
   })
 
   const onScrollRef = useRef(() => {})
@@ -429,43 +621,210 @@ export function ChatView({
     /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
     if (local === null) return
     const el = scrollerOf(local)
-    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
-    atBottomRef.current = isAtBottom
-    setAtBottom(isAtBottom)
-    const position = isAtBottom ? null : scrollPosition(local, el)
-    if (isAtBottom) {
+    const gesture = readerGestureRef.current
+    if (gesture !== null) {
+      const previousTop = gesture.lastTop
+      // A tail shrink can clamp the old scrollTop to the new physical floor
+      // before its scroll event arrives. Exclude that forced portion while
+      // retaining any reader movement beyond the clamp.
+      const currentFloor = Math.max(0, el.scrollHeight - el.clientHeight)
+      const baselineTop = Math.min(previousTop, currentFloor)
+      const deltaTop = el.scrollTop - baselineTop
+      gesture.lastTop = el.scrollTop
+      if (Math.abs(deltaTop) > 0.5) {
+        gesture.awayDistance = Math.max(0, gesture.awayDistance - deltaTop)
+        if (bottomOwnedRef.current) {
+          readerAwayRef.current = gesture.awayDistance
+          if (!recordReaderPosition(local, el, gesture.awayDistance <= FOLLOW_THRESHOLD + 1)) {
+            readerAwayRef.current = 0
+          }
+        } else {
+          // Only reader motion toward the floor may reclaim follow ownership;
+          // shrink/clamp or an upward gesture must preserve reading mode.
+          const ownsBottom = recordReaderPosition(local, el, deltaTop > 0
+            ? undefined
+            : false)
+          if (ownsBottom) {
+            const physicalAway = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight)
+            gesture.awayDistance = physicalAway
+            readerAwayRef.current = physicalAway
+          } else {
+            readerAwayRef.current = 0
+          }
+        }
+      } else if (!bottomOwnedRef.current && Math.abs(el.scrollTop - previousTop) > 0.5) {
+        recordReaderPosition(local, el, false)
+      }
+      gesture.epoch += 1
+      scheduleReaderGestureEndRef.current(gesture)
+      observedTopRef.current = el.scrollTop
+      return
+    }
+
+    if (bottomOwnedRef.current) {
       anchorRef.current = null
-    } else if (anchorRef.current !== null && position !== null) {
-      anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
+      const distance = el.scrollHeight - el.scrollTop - el.clientHeight
+      if (Math.abs(distance) > 1) writeBottom(el)
+      else {
+        readerAwayRef.current = 0
+        chatScroll.save(null)
+      }
+      observedTopRef.current = el.scrollTop
+      return
     }
-    // Continuous save (unmount happens after ref detach, so saving there is
-    // too late); pinned-to-bottom clears so a remount keeps following.
-    if (isAtBottom) chatScroll.save(null)
-    else if (position !== null) chatScroll.save(position)
+
+    // A layout/programmatic event while reading may update the saved semantic
+    // position, but cannot silently reclaim or release bottom ownership.
+    const position = scrollPosition(local, el)
+    if (position !== null) chatScroll.save(position)
+    observedTopRef.current = el.scrollTop
   }
 
-  // Bind scroll to the resolved scrollport (host or local) once per mount.
+  // Bind scroll and its reader-input provenance to the resolved scrollport.
   useEffect(() => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
     if (local === null) return
     const el = scrollerOf(local)
+    let wheelStartTop = el.scrollTop
+    let wheelStartBottom = el.scrollHeight - el.clientHeight
+    let wheelCanMoveHost = false
+    let directPointer: {
+      id: number
+      lastY: number
+      startBottom: number
+      startTop: number
+      target: EventTarget | null
+    } | null = null
+    let tabStart: { bottom: number; top: number } | null = null
+    let tabClearTimer: ReturnType<typeof setTimeout> | null = null
+
+    const clearTabStart = (): void => {
+      tabStart = null
+      if (tabClearTimer !== null) {
+        clearTimeout(tabClearTimer)
+        tabClearTimer = null
+      }
+    }
+
     const onScroll = (): void => { onScrollRef.current() }
+    const onWheelCapture = (event: WheelEvent): void => {
+      // Passive wheel delivery may observe compositor-updated geometry before
+      // the main-thread scroll event. The last delivered/written top remains
+      // the authoritative pre-input baseline.
+      wheelStartTop = observedTopRef.current
+      wheelStartBottom = el.scrollHeight - el.clientHeight
+      const direction: VerticalDirection = event.deltaY < 0 ? -1 : 1
+      wheelCanMoveHost = event.deltaY !== 0 && (direction < 0
+        ? wheelStartTop > 1
+        : wheelStartTop < wheelStartBottom - 1)
+    }
+    const onWheel = (event: WheelEvent): void => {
+      if (event.ctrlKey || event.deltaY === 0) return
+      const direction: VerticalDirection = event.deltaY < 0 ? -1 : 1
+      if (!wheelCanMoveHost) return
+      if (nestedOwnsVerticalScroll(event.target, el, direction)) return
+      armReaderGesture(wheelStartTop, wheelStartBottom)
+    }
+    const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+      if (event.key === 'Tab') {
+        if (event.defaultPrevented) clearTabStart()
+        return
+      }
+      if (event.defaultPrevented) return
+      if (!(event.target instanceof Node) || !el.contains(event.target)) return
+      const direction = keyDirection(event)
+      if (direction === null) return
+      if (consumesScrollKey(event)) return
+      if (!canScrollVertically(el, direction)) return
+      if (nestedOwnsVerticalScroll(event.target, el, direction)) return
+      armReaderGesture(el.scrollTop, el.scrollHeight - el.clientHeight)
+    }
+    const onTabKeyCapture = (event: globalThis.KeyboardEvent): void => {
+      if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return
+      const start = { bottom: el.scrollHeight - el.clientHeight, top: el.scrollTop }
+      tabStart = start
+      if (tabClearTimer !== null) clearTimeout(tabClearTimer)
+      // The browser's default Tab focus and focus-induced scroll happen after
+      // keydown propagation but before the next task. A microtask would clear
+      // this provenance before focusin can consume it.
+      tabClearTimer = setTimeout(() => {
+        if (tabStart === start) tabStart = null
+        tabClearTimer = null
+      }, 0)
+    }
+    const onFocusIn = (): void => {
+      const start = tabStart
+      clearTabStart()
+      if (start !== null) armReaderGesture(start.top, start.bottom)
+    }
+    const onPointerDown = (event: PointerEvent): void => {
+      if (!event.isPrimary) return
+      if (event.pointerType === 'mouse') {
+        // Native scrollbar/track events target the scrollport; ordinary flow
+        // controls target their own element and must not pause follow.
+        if (event.button === 0 && event.target === el) {
+          armReaderGesture(el.scrollTop, el.scrollHeight - el.clientHeight, true, event.pointerId)
+        }
+        return
+      }
+      directPointer = {
+        id: event.pointerId,
+        lastY: event.clientY,
+        startBottom: el.scrollHeight - el.clientHeight,
+        startTop: el.scrollTop,
+        target: event.target,
+      }
+    }
+    const onPointerMove = (event: PointerEvent): void => {
+      const pointer = directPointer
+      if (pointer === null || event.pointerId !== pointer.id) return
+      const delta = pointer.lastY - event.clientY
+      pointer.lastY = event.clientY
+      if (Math.abs(delta) < 2) return
+      const direction: VerticalDirection = delta < 0 ? -1 : 1
+      if (!canScrollVertically(el, direction)) return
+      if (nestedOwnsVerticalScroll(pointer.target, el, direction)) return
+      armReaderGesture(pointer.startTop, pointer.startBottom, true, pointer.id)
+    }
+    const releasePointer = (event: PointerEvent, cancelled: boolean): void => {
+      if (!event.isPrimary) return
+      if (directPointer?.id === event.pointerId) directPointer = null
+      const gesture = readerGestureRef.current
+      if (gesture === null || !gesture.held || gesture.pointerId !== event.pointerId) return
+      gesture.held = false
+      gesture.pointerId = null
+      gesture.epoch += 1
+      if (cancelled) schedulePointerCancelEndRef.current(gesture)
+      else scheduleReaderGestureEndRef.current(gesture)
+    }
+    const onPointerUp = (event: PointerEvent): void => { releasePointer(event, false) }
+    const onPointerCancel = (event: PointerEvent): void => { releasePointer(event, true) }
     el.addEventListener('scroll', onScroll, { passive: true })
-    return () => { el.removeEventListener('scroll', onScroll) }
-  }, [])
-
-  // The ref starts null and is assigned every render, so the placeholder
-  // initializer a function initial value would need never exists.
-  const followRef = useRef<(() => void) | null>(null)
-  followRef.current = () => {
-    const local = listRef.current
-    if (local !== null && atBottomRef.current) {
-      const el = scrollerOf(local)
-      el.scrollTop = el.scrollHeight
-      chatScroll.save(null)
+    el.addEventListener('wheel', onWheelCapture, { capture: true, passive: true })
+    el.addEventListener('wheel', onWheel, { passive: true })
+    el.addEventListener('focusin', onFocusIn)
+    el.addEventListener('pointerdown', onPointerDown, { passive: true })
+    window.addEventListener('keydown', onTabKeyCapture, { capture: true })
+    window.addEventListener('keydown', onKeyDown)
+    window.addEventListener('pointermove', onPointerMove, { passive: true })
+    window.addEventListener('pointerup', onPointerUp, { passive: true })
+    window.addEventListener('pointercancel', onPointerCancel, { passive: true })
+    return () => {
+      cancelReaderGesture()
+      clearTabStart()
+      el.removeEventListener('scroll', onScroll)
+      el.removeEventListener('wheel', onWheelCapture, true)
+      el.removeEventListener('wheel', onWheel)
+      el.removeEventListener('focusin', onFocusIn)
+      el.removeEventListener('pointerdown', onPointerDown)
+      window.removeEventListener('keydown', onTabKeyCapture, true)
+      window.removeEventListener('keydown', onKeyDown)
+      window.removeEventListener('pointermove', onPointerMove)
+      window.removeEventListener('pointerup', onPointerUp)
+      window.removeEventListener('pointercancel', onPointerCancel)
     }
-  }
+  }, [])
   // Streaming, tool disclosures, and other flow changes resize the column;
   // the sticky composer resizes outside it. This observer owns ChatView's
   // dynamic-height follow decisions and writes only while the reader is pinned.
@@ -494,6 +853,7 @@ export function ChatView({
       const el = scrollerOf(local)
       const row = pagingAnchor(local, el)
       if (row !== null && row.dataset.chatAnchorKey !== undefined) {
+        cancelReaderGesture()
         anchorRef.current = {
           key: row.dataset.chatAnchorKey,
           top: flowTop(row, el),
@@ -611,7 +971,7 @@ export function ChatView({
             <PendingSteeringBubble key={item.id} content={item.content} t={t} />
           ))}
         </div>
-        {!atBottom && (
+        {!bottomOwned && (
           <div className={css.toBottomSlot}>
             <button
               type="button"

+ 330 - 22
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -157,6 +157,56 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
 }
 
+function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
+  let scrollHeight = initialHeight
+  let scrollTop = 0
+  Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
+  Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
+  Object.defineProperty(element, 'scrollTop', {
+    configurable: true,
+    get: () => scrollTop,
+    set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
+  })
+  return {
+    setHeight: (value: number) => {
+      scrollHeight = value
+      scrollTop = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight))
+    },
+    setLayout: (height: number, top: number) => {
+      scrollHeight = height
+      scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
+    },
+  }
+}
+
+/** Simulate the browser order: reader input precedes the host scroll event. */
+function readerScroll(element: HTMLElement, top: number): void {
+  fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
+  element.scrollTop = top
+  fireEvent.scroll(element)
+}
+
+/** Settle test-installed metrics at the owned floor before reader input. */
+function settleAtBottom(element: HTMLElement): void {
+  element.scrollTop = element.scrollHeight - element.clientHeight
+  fireEvent.scroll(element)
+}
+
+function installAnimationFrameQueue() {
+  let callbacks: FrameRequestCallback[] = []
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+    callbacks.push(callback)
+    return callbacks.length
+  })
+  return {
+    flush: () => {
+      const frame = callbacks
+      callbacks = []
+      act(() => { frame.forEach((callback) => { callback(performance.now()) }) })
+    },
+  }
+}
+
 describe('chat-flow derivation', () => {
   it('groups consecutive tool results and keeps stable keys', () => {
     const nodes: ConversationNode[] = [
@@ -269,15 +319,14 @@ describe('ChatView', () => {
     )
     Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-    scroller.scrollTop = 50
-    fireEvent.scroll(scroller)
+    settleAtBottom(scroller)
+    readerScroll(scroller, 50)
     fireEvent.click(view.getByText('加载更早'))
     // The reader moves after the request starts; this, not the click-time
     // row, is the intent the arriving page must preserve.
     firstTop = -200
     nextTop = 60
-    scroller.scrollTop = 90
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 90)
     Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
     nextTop = 560
     act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
@@ -662,19 +711,21 @@ describe('ChatView', () => {
     // jsdom has no layout: fake the metrics the anchor math reads.
     Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
+    settleAtBottom(scroller)
     const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
     let anchoredTop = 100
     vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
       () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
     )
-    scroller.scrollTop = 80
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 80)
     // Arm the paging anchor, then deliver an older page (head seq decreases).
     fireEvent.click(view.getByText('加载更早'))
     Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
     anchoredTop = 700
     act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
     expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
+    fireEvent.scroll(scroller) // delayed event from the semantic correction stays reader-owned
+    expect(scroller.scrollTop).toBe(680)
     // A new trailing user bubble (own words) force-scrolls to the bottom.
     act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
     expect(scroller.scrollTop).toBe(1600)
@@ -695,8 +746,8 @@ describe('ChatView', () => {
     try {
       Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
       Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-      scroller.scrollTop = 80
-      fireEvent.scroll(scroller)
+      settleAtBottom(scroller)
+      readerScroll(scroller, 80)
       fireEvent.click(view.getByText('加载更早'))
       // Total height grows by 500, but only 300 belongs before the call row.
       Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
@@ -723,8 +774,8 @@ describe('ChatView', () => {
     try {
       Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
       Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-      scroller.scrollTop = 80
-      fireEvent.scroll(scroller)
+      settleAtBottom(scroller)
+      readerScroll(scroller, 80)
       fireEvent.click(view.getByText('加载更早'))
       Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
       prepended = true
@@ -742,8 +793,8 @@ describe('ChatView', () => {
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
     Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-    scroller.scrollTop = 50
-    fireEvent.scroll(scroller)
+    settleAtBottom(scroller)
+    readerScroll(scroller, 50)
     fireEvent.click(view.getByText('加载更早'))
     fireEvent.click(view.getByLabelText('回到底部'))
     Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
@@ -758,8 +809,8 @@ describe('ChatView', () => {
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
     Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
-    scroller.scrollTop = 100 // far from bottom
-    fireEvent.scroll(scroller)
+    settleAtBottom(scroller)
+    readerScroll(scroller, 100) // far from bottom
     const backButton = view.getByLabelText('回到底部')
     expect(backButton).toBeTruthy()
     // Streaming growth must NOT drag a scrolled-away reader down.
@@ -771,6 +822,253 @@ describe('ChatView', () => {
     expect(view.queryByLabelText('回到底部')).toBeNull()
   })
 
+  it('re-pins a UA layout scroll after the bottom write has settled', () => {
+    let notify: (() => void) | undefined
+    class ResizeObserverStub {
+      constructor(callback: ResizeObserverCallback) {
+        notify = () => { callback([], this as unknown as ResizeObserver) }
+      }
+
+      observe = vi.fn()
+      disconnect = vi.fn()
+    }
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    settleAtBottom(scroller)
+    readerScroll(scroller, 100)
+    fireEvent.click(view.getByLabelText('回到底部'))
+    expect(scroller.scrollTop).toBe(700)
+    fireEvent.scroll(scroller) // delivery from the completed bottom write
+
+    // A stream-finalization shrink can clamp scrollTop before Markdown reflow
+    // restores the taller layout. The eventual UA scroll has no reader input
+    // and must restore ownership even though it differs from the old target.
+    fireEvent.wheel(scroller, { deltaY: 120 }) // no-op wheel at the physical bottom
+    metrics.setLayout(1_040, 500)
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(740)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    expect(h.chatScroll.read()).toBeNull()
+
+    metrics.setHeight(1_200)
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(900)
+  })
+
+  it('keeps a reader gesture active until its small scrolls cross the follow threshold', () => {
+    let notify: (() => void) | undefined
+    class ResizeObserverStub {
+      constructor(callback: ResizeObserverCallback) {
+        notify = () => { callback([], this as unknown as ResizeObserver) }
+      }
+
+      observe = vi.fn()
+      disconnect = vi.fn()
+    }
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    settleAtBottom(scroller)
+    readerScroll(scroller, 100)
+    fireEvent.click(view.getByLabelText('回到底部'))
+
+    fireEvent.wheel(scroller, { deltaY: -120 })
+    scroller.scrollTop = 695
+    fireEvent.scroll(scroller)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    scroller.scrollTop = 680
+    fireEvent.scroll(scroller)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    scroller.scrollTop = 650
+    fireEvent.scroll(scroller)
+    metrics.setHeight(1_200)
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(650)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    expect(h.chatScroll.read()?.scrollTop).toBe(650)
+  })
+
+  it('keeps the first wheel baseline when browser scroll events coalesce', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    installScrollMetrics(scroller, 1_000, 300)
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+
+    fireEvent.wheel(scroller, { deltaY: -120 })
+    scroller.scrollTop = 650
+    fireEvent.wheel(scroller, { deltaY: -120 })
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(650)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
+  it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    installScrollMetrics(scroller, 1_000, 300)
+    settleAtBottom(scroller)
+
+    // Chromium's compositor can update scrollTop before passive wheel
+    // listeners run; the main-thread scroll event is still pending here.
+    scroller.scrollTop = 500
+    fireEvent.wheel(scroller, { deltaY: -200 })
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
+  it('separates a small reader delta from layout growth before the gesture begins', () => {
+    let notify: (() => void) | undefined
+    class ResizeObserverStub {
+      constructor(callback: ResizeObserverCallback) {
+        notify = () => { callback([], this as unknown as ResizeObserver) }
+      }
+
+      observe = vi.fn()
+      disconnect = vi.fn()
+    }
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    const frames = installAnimationFrameQueue()
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+
+    metrics.setHeight(1_040)
+    fireEvent.wheel(scroller, { deltaY: -120 })
+    scroller.scrollTop = 695
+    fireEvent.scroll(scroller)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(695)
+    frames.flush()
+    frames.flush()
+    expect(scroller.scrollTop).toBe(740)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+  })
+
+  it('does not treat an active-gesture tail clamp as reader movement', () => {
+    const frames = installAnimationFrameQueue()
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    settleAtBottom(scroller)
+
+    fireEvent.wheel(scroller, { deltaY: -5 })
+    scroller.scrollTop = 695
+    fireEvent.scroll(scroller)
+    metrics.setHeight(800) // floor 500 clamps the active gesture's old top
+    fireEvent.scroll(scroller)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+
+    scroller.scrollTop = 400
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    metrics.setHeight(600) // floor 300 clamps while reading in the same gesture
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    metrics.setHeight(200) // a non-scrollable floor clamps to zero, never negative
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    frames.flush()
+    frames.flush()
+  })
+
+  it('does not arm transcript ownership while a nested overflow consumes the wheel', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    const nested = document.createElement('div')
+    nested.style.overflowY = 'auto'
+    Object.defineProperty(nested, 'scrollHeight', { value: 400, configurable: true })
+    Object.defineProperty(nested, 'clientHeight', { value: 100, configurable: true })
+    Object.defineProperty(nested, 'scrollTop', { value: 200, writable: true, configurable: true })
+    scroller.appendChild(nested)
+
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+    fireEvent.wheel(nested, { deltaY: -120 })
+    metrics.setLayout(1_000, 500)
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(700)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+
+    nested.scrollTop = 0
+    fireEvent.wheel(nested, { deltaY: -120 })
+    scroller.scrollTop = 500
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(500)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+
+    nested.scrollTop = 300
+    nested.addEventListener('wheel', (event) => {
+      event.preventDefault()
+      scroller.scrollTop = 700
+    }, { once: true })
+    fireEvent.wheel(nested, { deltaY: 120 })
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(700)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+  })
+
+  it('uses host navigation keys without stealing editing keys', async () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    const button = document.createElement('button')
+    const link = document.createElement('a')
+    link.href = '#target'
+    const textarea = document.createElement('textarea')
+    scroller.append(button, link, textarea)
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+
+    fireEvent.keyDown(button, { key: 'PageUp' })
+    scroller.scrollTop = 500
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+
+    fireEvent.click(view.getByLabelText('回到底部'))
+    fireEvent.keyDown(link, { key: ' ', shiftKey: true })
+    scroller.scrollTop = 500
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    fireEvent.click(view.getByLabelText('回到底部'))
+
+    fireEvent.keyDown(textarea, { key: 'ArrowUp' })
+    metrics.setLayout(1_000, 500)
+    fireEvent.scroll(scroller)
+    expect(scroller.scrollTop).toBe(700)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+
+    fireEvent.keyDown(textarea, { key: 'PageUp' })
+    scroller.scrollTop = 500
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    fireEvent.click(view.getByLabelText('回到底部'))
+
+    fireEvent.keyDown(textarea, { key: 'Tab' })
+    // Chromium runs microtasks before the default Tab focus transition. The
+    // provenance must survive that checkpoint until focusin consumes it.
+    await Promise.resolve()
+    fireEvent.focusIn(button)
+    scroller.scrollTop = 500
+    fireEvent.scroll(scroller)
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
   it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
     let notify: (() => void) | undefined
     const observe = vi.fn()
@@ -793,8 +1091,7 @@ describe('ChatView', () => {
     Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
     act(() => { notify?.() })
     expect(scroller.scrollTop).toBe(1_200)
-    scroller.scrollTop = 200
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 200)
     Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
     act(() => { notify?.() })
     expect(scroller.scrollTop).toBe(200)
@@ -802,17 +1099,28 @@ describe('ChatView', () => {
   })
 
   it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
+    const frames = installAnimationFrameQueue()
     const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
     const view = render(<h.ChatView {...h.props} />)
     const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
     Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
+    settleAtBottom(scroller)
     // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
     // re-render from setAtBottom must not force scrollTop to scrollHeight.
-    scroller.scrollTop = 690 // distance-to-bottom = 10
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 690) // distance-to-bottom = 10
     expect(view.queryByLabelText('回到底部')).toBeNull()
     expect(scroller.scrollTop).toBe(690)
+    frames.flush()
+    frames.flush()
+    expect(scroller.scrollTop).toBe(690)
+    const button = document.createElement('button')
+    scroller.appendChild(button)
+    fireEvent.keyDown(button, { key: 'Tab' })
+    fireEvent.focusIn(button) // visible focus target: no host scroll follows
+    frames.flush()
+    frames.flush()
+    expect(scroller.scrollTop).toBe(690)
   })
 
   it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
@@ -827,8 +1135,7 @@ describe('ChatView', () => {
       const view = render(<h.ChatView {...h.props} />, { container: host })
       // Open jump uses the host, not the local .scroll node.
       expect(host.scrollTop).toBe(2000)
-      host.scrollTop = 100
-      fireEvent.scroll(host)
+      readerScroll(host, 100)
       expect(view.getByLabelText('回到底部')).toBeTruthy()
       fireEvent.click(view.getByLabelText('回到底部'))
       expect(host.scrollTop).toBe(2000)
@@ -860,14 +1167,15 @@ describe('ChatView', () => {
       const view = render(<h.ChatView {...h.props} />, { container: host })
       expect(host.scrollTop).toBe(2000)
       // Reader scrolls up; the position is recorded continuously.
-      host.scrollTop = 100
-      fireEvent.scroll(host)
+      readerScroll(host, 100)
       // View-tab switch away and back: the view unmounts, then remounts.
       view.rerender(<div />)
       anchorTop = 560
       host.scrollTop = 0
       view.rerender(<h.ChatView {...h.props} />)
       expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
+      fireEvent.scroll(host) // delayed semantic-restore event remains away from bottom
+      expect(host.scrollTop).toBe(580)
       // The restored position is above the floor: follow stays disarmed.
       expect(view.getByLabelText('回到底部')).toBeTruthy()
     } finally {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است