ソースを参照

fix(web): narrow chat scroll provenance

kingwl 1 ヶ月 前
コミット
9b91e5312c

+ 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: f7cc843f322967ac6a66457aee237bc74174263d
-2026-07-29-sticky-composer-conversation-scroll.zh.md: 22d59294e064beacf86680c4b45355a51f47213e
+2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b
+2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5

ファイルの差分が大きいため隠しています
+ 0 - 0
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md


ファイルの差分が大きいため隠しています
+ 0 - 0
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md


+ 72 - 387
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -38,68 +38,6 @@ 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 {
@@ -397,19 +335,13 @@ export function ChatView({
 
   const listRef = useRef<HTMLDivElement | null>(null)
   const columnRef = useRef<HTMLDivElement | null>(null)
-  /** 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 atBottomRef = useRef(true)
+  const [atBottom, setAtBottom] = useState(true)
+  /** Last position 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)
+  /** Pre-input position for the current wheel gesture. */
+  const wheelStartRef = useRef<number | null>(null)
+  const wheelEpochRef = useRef(0)
   /** 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)
@@ -428,117 +360,17 @@ 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 writeBottom = (el: HTMLElement): void => {
+  const toBottom = (el: HTMLElement): void => {
+    wheelStartRef.current = null
+    wheelEpochRef.current += 1
+    anchorRef.current = null
     el.scrollTop = el.scrollHeight
     observedTopRef.current = el.scrollTop
-    readerAwayRef.current = 0
+    atBottomRef.current = true
+    setAtBottom(true)
     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. */
@@ -553,21 +385,16 @@ 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
+        observedTopRef.current = el.scrollTop
         const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
-        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)
-        }
+        atBottomRef.current = isAtBottom
+        setAtBottom(isAtBottom)
+        const normalized = isAtBottom ? null : scrollPosition(local, el)
+        if (isAtBottom) chatScroll.save(null)
+        else if (normalized !== null) chatScroll.save(normalized)
       }
       firstSeqRef.current = firstSeq
       lastKeyRef.current = lastKey
@@ -582,16 +409,8 @@ 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
@@ -610,9 +429,8 @@ export function ChatView({
     lastSteeringIdRef.current = lastSteeringId
     followSigRef.current = followSig
     // Follow new flow content while pinned; do NOT re-pin on every render
-    // merely because bottomOwnedRef is true (scroll threshold → setState → snap).
-    if (appendedUser || appendedSteering
-      || (tipMoved && bottomOwnedRef.current && readerGestureRef.current === null)) toBottom(el)
+    // merely because atBottomRef is true (scroll threshold → setState → snap).
+    if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
   })
 
   const onScrollRef = useRef(() => {})
@@ -621,210 +439,78 @@ export function ChatView({
     /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
     if (local === null) return
     const el = scrollerOf(local)
-    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
+    // Only wheel input may make raw scroll geometry change follow ownership.
+    // Browser clamping and delayed programmatic scroll events otherwise have
+    // the same event shape and must preserve the current ownership state.
+    const floor = Math.max(0, el.scrollHeight - el.clientHeight)
+    const wheelStart = wheelStartRef.current
+    const movedByWheel = wheelStart !== null
+      && Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
+    const isAtBottom = movedByWheel
+      ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
+      : atBottomRef.current
+    if (!movedByWheel && isAtBottom) {
+      toBottom(el)
       return
     }
-
-    if (bottomOwnedRef.current) {
+    atBottomRef.current = isAtBottom
+    setAtBottom(isAtBottom)
+    const position = isAtBottom ? null : scrollPosition(local, el)
+    if (isAtBottom) {
       anchorRef.current = null
-      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
+    } else if (anchorRef.current !== null && position !== null) {
+      anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
     }
-
-    // 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)
+    // 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)
     observedTopRef.current = el.scrollTop
   }
 
-  // Bind scroll and its reader-input provenance to the resolved scrollport.
+  // Bind scroll and the wheel provenance needed to distinguish reader input
+  // from layout-driven scrolls on the resolved scrollport once per mount.
   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 startTop = observedTopRef.current
+      const floor = Math.max(0, el.scrollHeight - el.clientHeight)
+      const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
+      if (!canMove) return
+      wheelStartRef.current = startTop
+      const epoch = ++wheelEpochRef.current
+      requestAnimationFrame(() => {
+        requestAnimationFrame(() => {
+          if (wheelEpochRef.current === epoch) wheelStartRef.current = null
+        })
+      })
     }
-    const onPointerUp = (event: PointerEvent): void => { releasePointer(event, false) }
-    const onPointerCancel = (event: PointerEvent): void => { releasePointer(event, true) }
     el.addEventListener('scroll', onScroll, { passive: true })
-    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 })
+    el.addEventListener('wheel', onWheel, { capture: true, passive: true })
     return () => {
-      cancelReaderGesture()
-      clearTabStart()
+      wheelStartRef.current = null
       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)
+      el.removeEventListener('wheel', onWheel, true)
     }
   }, [])
+
+  // 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
+      observedTopRef.current = el.scrollTop
+      chatScroll.save(null)
+    }
+  }
   // 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.
@@ -853,7 +539,6 @@ 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),
@@ -971,7 +656,7 @@ export function ChatView({
             <PendingSteeringBubble key={item.id} content={item.content} t={t} />
           ))}
         </div>
-        {!bottomOwned && (
+        {!atBottom && (
           <div className={css.toBottomSlot}>
             <button
               type="button"

+ 16 - 273
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -157,6 +157,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
 }
 
+/** Simulate reader input before the browser delivers 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)
+}
+
 function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
   let scrollHeight = initialHeight
   let scrollTop = 0
@@ -168,10 +175,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
     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))
-    },
+    setHeight: (value: number) => { scrollHeight = value },
     setLayout: (height: number, top: number) => {
       scrollHeight = height
       scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
@@ -179,34 +183,6 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
   }
 }
 
-/** 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[] = [
@@ -319,7 +295,6 @@ describe('ChatView', () => {
     )
     Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
     Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-    settleAtBottom(scroller)
     readerScroll(scroller, 50)
     fireEvent.click(view.getByText('加载更早'))
     // The reader moves after the request starts; this, not the click-time
@@ -711,7 +686,6 @@ 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(
@@ -724,8 +698,6 @@ describe('ChatView', () => {
     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)
@@ -746,7 +718,6 @@ describe('ChatView', () => {
     try {
       Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
       Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-      settleAtBottom(scroller)
       readerScroll(scroller, 80)
       fireEvent.click(view.getByText('加载更早'))
       // Total height grows by 500, but only 300 belongs before the call row.
@@ -774,7 +745,6 @@ describe('ChatView', () => {
     try {
       Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
       Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
-      settleAtBottom(scroller)
       readerScroll(scroller, 80)
       fireEvent.click(view.getByText('加载更早'))
       Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
@@ -793,7 +763,6 @@ 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 })
-    settleAtBottom(scroller)
     readerScroll(scroller, 50)
     fireEvent.click(view.getByText('加载更早'))
     fireEvent.click(view.getByLabelText('回到底部'))
@@ -809,7 +778,6 @@ 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 })
-    settleAtBottom(scroller)
     readerScroll(scroller, 100) // far from bottom
     const backButton = view.getByLabelText('回到底部')
     expect(backButton).toBeTruthy()
@@ -822,31 +790,17 @@ 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)
+  it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
     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
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
 
-    // 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
+    // The wheel cannot move farther down. A stream-finalization shrink clamps
+    // the old position, then reflow grows the layout before scroll delivery.
+    fireEvent.wheel(scroller, { deltaY: 120 })
     metrics.setLayout(1_040, 500)
     fireEvent.scroll(scroller)
     expect(scroller.scrollTop).toBe(740)
@@ -854,217 +808,20 @@ describe('ChatView', () => {
     expect(h.chatScroll.read()).toBeNull()
 
     metrics.setHeight(1_200)
-    act(() => { notify?.() })
+    act(() => { h.set({ running: true }) })
     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.wheel(scroller, { deltaY: -200 })
     fireEvent.scroll(scroller)
     expect(view.getByLabelText('回到底部')).toBeTruthy()
   })
@@ -1099,28 +856,16 @@ 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.
     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', () => {
@@ -1174,8 +919,6 @@ describe('ChatView', () => {
       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 {

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません