# Agent Note: 经由 observed-top ledger 的读者滚动归因

Status: implemented

[English](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) | 中文

## 问题

ChatView 的贴底跟随此前只把滚轮／触控板手势识别为读者输入：钉在底部（floor）期间，一个没有对应滚轮位移的滚动事件会被视为程序化滚动并被拉回底部。因此触控平移、拖动原生滚动条与键盘翻页都无法离开流式 transcript（文本记录）的底部，在手机上尾部实际上被锁死。这种仅认滚轮的输入来源判定是 [sticky-composer 笔记](2026-07-29-sticky-composer-conversation-scroll.md)中有意的暂缓：该笔记拒绝为「此次窄范围修复」建立通用输入状态机，把其余所有滚动来源都留在模型之外。

## 决策

读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger（`observedTopRef`）：即最近一次由主线程交付、或由组件自身写入的 `scrollTop`，并在每一个程序化写入点（贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身）同步记录。滚动事件到达时，偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入；落在 ledger 上的位置（迟到的程序化交付），或恰好落在收缩后底部上的位置（内容收缩后的浏览器钳制），则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化：位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底，超出则释放跟随并显示「回到底部」。滚轮监听器及其 epoch 簿记已删除；组件只监听 `scroll`，因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。

## 契约变更：收缩与重新增长被合并的钳制

如果一次收缩钳制的布局在同一次渲染更新内、赶在该钳制的滚动事件交付之前重新增长，那么这个事件在几何上与读者输入无法区分，因此它现在会被判读为读者并释放跟随（可经「回到底部」恢复）。现实中由 React 提交（commit）驱动的收缩与重新增长仍会被吸收：layout effect 中的跟随会在每次提交后重新贴底并重新记录 ledger，而仅收缩的钳制会恰好落在 `min(ledger, floor)` 上。只有在同一次更新内先收缩再重新增长的非 React 重排会被误归因。旧的滚轮模型在这种竞态情形下会保持跟随；单元测试契约已在同一变更中改写为只吸收纯收缩的保证。

## 测试

`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 契约：`readerScroll` 辅助函数交付一个组件从未写入过的位置，程序化交付落在 ledger 上，流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)：在已停稳的 transcript 上做键盘翻页，以及对着按节奏推进的流式输出做一次触控式惯性快滑（momentum fling）；两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。

该车道的 Chromium 无法合成任何非滚轮的设备滚动，这限定了 e2e 能真实驱动的范围：触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件，却从不移动滚动容器（无头模式与 Xvfb 下的有头模式皆然）；`default` 手势来源合成的是滚轮事件；合成器滚动条则完全无视合成的鼠标输入，且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语，因此由它承担真实输入流水线的证明；快滑场景则把触控的特征（组件从未写入过的逐帧衰减位移）直接回放进滚动容器。

## 曾考虑的替代方案

**保留仅认滚轮的模型。** 否决：它本身就是缺陷所在。触控、滚动条与键盘读者无法从流式尾部夺走所有权，而每种新支持的设备都需要单独开一个特例。

**逐一枚举输入设备。** 在滚轮 epoch 旁边加挂 `touchstart`/`pointerdown`/`keydown` 监听器是最显而易见的扩展。否决：拖动原生滚动条在其滚动事件到达之前不暴露任何可供锁存的输入事件；设备清单会随浏览器新增输入来源而逐渐腐化；而且每个监听器都需要自己的合成器交付宽限窗口——这正是 sticky-composer 笔记当初就拒绝构建的那个输入状态机。

**用启发式吸收收缩与重新增长被合并的钳制。** 针对底部失配的宽限窗口，或推迟到 rAF 的复查，本可让这种竞态下的钳制不被判读为读者。否决：流式输出以分片节奏（24 ms）改写底部，而帧间隔约 16 ms，因此任何宽限窗口要么会在流式输出期间吞掉真实的触控输入（重新打开本次变更所修复的缺陷），要么短到盖不住它想针对的竞态。转而接受这一误归因，它是可恢复的。

**在 e2e 中驱动真实的触控与滚动条设备。** 否决来自环境，而非偏好取舍：每条合成路径（CDP 触控手势、触控事件序列、经典滚动条上的合成鼠标、Xvfb 下的有头模式）都逐一试过，均无法滚动；细节见上文「测试」一节。

## 后果

每种读者输入现在都以同一方式拥有贴底跟随，而代码更少：滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除，归因搭载在组件本就维护的状态之上。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留，仍为权威；其窄范围的输入来源规则由本笔记取代。代价就是上文的契约变更：一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随，直到读者回到底部或按下「回到底部」；以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围；若手势合成在未来某个 Chromium 版本中开始可用，可以在不改变所断言契约的前提下，把快滑模拟替换为真实的触控划动。
