Răsfoiți Sursa

fix(chat): keep genuine near-floor scroll gestures pending

Tianyi Cui 1 lună în urmă
părinte
comite
9ef426d729

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.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-09-07-pinned-scroll-delivery-before-layout.md
-2026-09-07-pinned-scroll-delivery-before-layout.md: 7a00cf653824df13272fcf0cc2baf35b298f170c
-2026-09-07-pinned-scroll-delivery-before-layout.zh.md: d78c388e4e7a8f6f2fa6070149e652e0e25cc358
+2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45
+2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md

@@ -10,7 +10,7 @@ A delayed scroll sample compares positions from different layouts. While Chat is
 
 ## Decision
 
-[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) samples pinned scroll deliveries synchronously through the same sample operation that clears pending work. This preserves the existing observed-top comparison for genuine reader movement and releases layout follow before further growth. Pinned samples use scroll metrics, not semantic-row geometry; moving away still disarms follow immediately. Away-reader samples remain coalesced at the existing interval or `scrollend`.
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 通过同一个清除待处理工作的采样操作,同步采样贴底滚动事件。该机制保留现有的已观察顶部位置比较来识别真实读者移动,并在后续增长前恢复布局跟随。贴底采样只读取滚动指标,不读取语义行几何;离底移动仍会立即关闭跟随。离底读者的采样仍合并到现有周期或 `scrollend` 时执行。
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。
 
 ## Alternatives considered
 

+ 2 - 2
packages/client/ui-chat/README.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 packages/client/ui-chat/README.md
-README.md: 34b66da24b35f8cfd4c26da9b759e7991c5cb856
-README.zh.md: a9b7c8fca3e8137950dd02cd5398c11f15344757
+README.md: fa9653f7acab47f352e59971b333c6e691f44191
+README.zh.md: f8dc1968fffc57336d6aece8fe07113d5b99893b

+ 1 - 1
packages/client/ui-chat/README.md

@@ -46,7 +46,7 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
 <a id="scroll-ownership"></a>
 ## Scroll ownership
 
-Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries update follow ownership immediately, before subsequent layout changes can invalidate their floor; away-reader anchor sampling remains coalesced until the sampling interval or `scrollend`. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
+Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
 
 -----
 

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -46,7 +46,7 @@ Chat 会为非空的初始请求、显式消息序列起点、真实 system 字
 <a id="scroll-ownership"></a>
 ## 滚动归属
 
-Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效;离底读者的锚点采样仍合并到采样周期或 `scrollend` 时执行。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
 
 -----
 

+ 14 - 6
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -24,6 +24,11 @@ function scrollerOf(from: HTMLElement): HTMLElement {
   return (from.closest('[data-conversation-scroll]')) ?? from
 }
 
+/** Browser shrink clamps and recorded writes do not transfer scroll ownership. */
+function readerMovedScroll(top: number, floor: number, observedTop: number): boolean {
+  return Math.abs(top - Math.min(observedTop, floor)) > 0.5
+}
+
 interface PagingAnchor {
   /** Stable node/call identity, independent of boundary-spanning group keys. */
   key: string
@@ -555,7 +560,7 @@ export function ChatView({
     // programmatic deliveries land on the ledger itself, so both preserve
     // the current ownership state.
     const floor = Math.max(0, el.scrollHeight - el.clientHeight)
-    const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5
+    const movedByReader = readerMovedScroll(el.scrollTop, floor, observedTopRef.current)
     const isAtBottom = movedByReader
       ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
       : atBottomRef.current
@@ -579,9 +584,9 @@ export function ChatView({
     scheduleActiveTurn()
   }
 
-  // Pinned deliveries must settle before layout growth can invalidate their
-  // floor. Away-reader anchor geometry stays coalesced until the interval or
-  // scrollend; pinned samples read only scroll metrics unless the reader leaves.
+  // Non-reader pinned deliveries must settle before layout growth invalidates
+  // their floor. Reader movement stays pending even inside the follow threshold,
+  // so growth cannot erase small gestures before they accumulate off the floor.
   useEffect(() => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
@@ -599,8 +604,11 @@ export function ChatView({
     const onScroll = (): void => {
       scrollSamplePendingRef.current = true
       if (atBottomRef.current) {
-        sample()
-        return
+        const floor = Math.max(0, el.scrollHeight - el.clientHeight)
+        if (!readerMovedScroll(el.scrollTop, floor, observedTopRef.current)) {
+          sample()
+          return
+        }
       }
       sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS)
     }

+ 34 - 0
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -2445,6 +2445,39 @@ describe('ChatView', () => {
     }
   })
 
+  it('lets small reader movements accumulate past the follow threshold during growth', () => {
+    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)
+    expect(notify).toBeDefined()
+    scroller.scrollTop = 700
+    fireEvent.scroll(scroller)
+    fireEvent(scroller, new Event('scrollend'))
+    scroller.scrollTop = 690
+    fireEvent.scroll(scroller)
+    metrics.setHeight(1_020)
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(690)
+    scroller.scrollTop = 680
+    fireEvent.scroll(scroller)
+    fireEvent(scroller, new Event('scrollend'))
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+    metrics.setHeight(1_040)
+    act(() => { notify?.() })
+    expect(scroller.scrollTop).toBe(680)
+  })
+
   it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
     const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
     const view = render(<h.ChatView {...h.props} />)
@@ -2476,6 +2509,7 @@ describe('ChatView', () => {
       fireEvent.scroll(scroller)
       scroller.scrollTop = 500
       fireEvent.scroll(scroller)
+      fireEvent(scroller, new Event('scrollend'))
       expect(view.getByLabelText('回到底部')).toBeTruthy()
       const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
       try {