fix(chat): attach scroll anchor when the scroller appears; only reader scroll releases a pin (BUG-1043, BUG-1044)

- The anchor listener and follow observer now attach whenever the scroller
  element itself appears (checked after every commit, no-op unless element,
  active or resetKey changed). The home page mounts `.conversation` after its
  loading screen with unchanged active/resetKey, so a directly opened session
  never got a listener, never landed on its newest content, showed the jump
  chip under short replies and did not follow after pressing it.
- After a pin, geometry no longer releases the hold: only a wheel, touch drag,
  scroll key or scrollbar press followed by a scroll within 1s does. The
  rectification pin rests 94px from the bottom, inside the 96px threshold,
  which dragged long replies to their last line.
- Real React lifecycle tests (loading screen -> reveal, 94px rest), DESIGN,
  BUG history, PROGRESS, CHANGELOG, device checklist and CDP screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 10:42:12 +08:00
co-authored by Claude Opus 5.5
parent e45d97019f
commit da2613ffd9
12 changed files with 792 additions and 108 deletions
+19 -19
View File
@@ -13962,29 +13962,29 @@
## BUG-1043 | 直接打开普通咨询会话后,滚动锚的监听从未挂上
- 状态:investigating(仅诊断;本轮任务书禁止改 `useConversationScrollAnchor`,未修)
- 状态:investigating(已修复、本地真实浏览器验证通过;等待 Claude 验收与 staging 部署后真机复核)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`frontend/src/hooks/use-conversation-scroll-anchor.ts` 两个 effect(scroll 监听、ResizeObserver 跟随)与首页 `page.tsx` 的加载闸门;普通咨询面。生时校正面不受影响(组件在揭幕后才挂载)。
- 用户现象(本地复现):刷新页面或经 `?c=` / 启动落点直接进入一个已有的普通咨询会话后,发一个短问题,回答很短也会出现「跳到最新」;上下滚动该按钮不消失;点它之后回答继续变长也不会跟随。
- 触发条件:首页揭幕时当前会话就是一条有消息的普通咨询(不是揭幕后再切换过去)。
- 已确认事实:无头 Chrome 151、生产构建、虚构数据下给 `EventTarget.addEventListener` 与 `ResizeObserver.observe` 打点:该路径下 `.conversation` 上 scroll 监听 0 次、ResizeObserver 观察 0 次;先开 `/?new=1` 再点侧栏进入同一会话,则各挂上 1 次,短回答不再出现「跳到最新」。`pinLatestTurn` 走 rAF 不依赖 effect,所以钉顶本身正常,但 `latestBelowFold` 停在钉顶那一刻算出的值。
- 推测根因(未证实):加载闸门期间 `page.tsx` 早退渲染 `.app-loading`,`.conversation` 尚未挂载,effect 因 `container.current` 为空直接返回;揭幕后 `active` 与 `resetKey`(`activeSessionId`)都没变,effect 不会重跑。
- 修复:未修。需要单独任务书授权改 hook(例如依赖里加入容器是否已挂载的状态)。
- 验证:见上,复现脚本在执行方 scratchpad,未入库。
- 防复发:待定。
- 相关记录:BUG-930、BUG-932、BUG-1042。
- 影响面:`frontend/src/hooks/use-conversation-scroll-anchor.ts` 的挂载逻辑(scroll 监听、ResizeObserver 跟随);首页 `page.tsx` 的加载闸门之后才挂载的 `.conversation`。生时校正面不受影响(组件在揭幕后才挂载,容器从首帧就在)。
- 用户现象:刷新页面、经 `?c=` 深链或启动落点直接进入一个已有的普通咨询会话后:没有落到最新一条(停在历史中段);发一个短问题,回答很短也会出现「跳到最新」;上下滚动该按钮不消失;点它之后回答继续变长也不会跟随。
- 触发条件:首页揭幕时当前会话就是一条有消息的普通咨询(不是揭幕后再切换过去)。先开 `/?new=1` 再点侧栏进入同一会话则正常。
- 根因(已证实):hook 的两个 effect 依赖 `[active, container, resetKey]`。加载闸门期间 `page.tsx` 早退渲染 `.app-loading`,`.conversation` 未挂载,两个 effect 因 `container.current` 为空直接返回;揭幕那次提交里 `active`(`!rectificationSurfaceOpen && !starterHomeVisible`)与 `resetKey`(`activeSessionId`)都没变,`container` 是同一个 ref 对象,effect 不重跑,于是 scroll 监听、ResizeObserver 与「打开会话先落底」三件事都没发生。`pinLatestTurn` 走 rAF 不依赖 effect,所以钉顶本身正常,但 `latestBelowFold` 停在钉顶那一刻(平滑滚动尚未开始时)算出的值。证据:新增的真实 React 生命周期测试(先渲染加载屏再揭幕)中 3 条 BUG-1043 用例对改前代码全部失败(scroll 监听 0、点「跳到最新」后不跟随、容器重挂载后旧元素仍挂着监听);无头 Chrome 插桩改前 `.conversation` scroll 监听 0、ResizeObserver 观察 0、打开后距底 1819px。
- 修复:挂载改由「滚动容器元素本身」驱动:一个每次提交后运行的 effect 比较 `container.current`、`active`、`resetKey` 与上次挂载时的记录,三者都没变就什么也不做;元素出现、换了、`active` 翻转或换会话时先卸下旧的,再对新元素挂 scroll 监听 + ResizeObserver/MutationObserver;卸载时另一个空依赖 effect 负责清理。调用处与 hook 签名不变(`page.tsx`、`rectification-agentic-chat.tsx` 未改)。选这一路而不是 callback ref,是因为两个调用处都把同一个 `RefObject` 另作他用,改 callback ref 必须改校正面组件(本轮并行任务在改);也不用「effect 里 setState 记录元素」,那会触发 react-hooks 编译器规则。
- 验证:`frontend/tests/conversation-scroll-anchor-lifecycle.test.tsx`(createRoot + act 真实生命周期,宿主节点只伪造几何与事件):「加载屏 → 揭幕」后 scroll 监听 1 个、ResizeObserver 有观察、落到最新、重复渲染不重复挂载、短回答平滑滚动落定后不显示「跳到最新」;长回答显示按钮、点击后继续跟随;同一会话下容器重挂载会迁到新元素、旧元素监听被卸下;`active` 关开会卸下 / 重挂。无头 Chrome 151 + 生产构建 + 虚构数据(`?c=` 直接打开、打开后刷新、先 `?new=1` 再点侧栏三种进入方式):改后 scroll 监听 1、观察 1、打开后距底 0;短回答「跳到最新」不出现(改前出现);长回答按钮出现,点击后落底,再插入 400px 仍距底 0(改前 400)。全量前端测试失败名单与基线逐条一致。截图 `docs/testing/scroll-anchor-hook-fixes-20260926/consult-reload-short-375-{before,after}.png`。
- 防复发:hook 不得再用只依赖 `active` / `resetKey` 的 effect 挂监听;生命周期测试锁住「加载屏 → 揭幕」路径。
- 相关记录:BUG-930、BUG-932、BUG-1042、BUG-1044。
- 复发自:无。
- 修复版本:无。
- 修复版本:`codex/scroll-anchor-hook-fixes-20260926`,未部署。
## BUG-1044 | 生时校正长回答钉顶后又被拉到底部
- 状态:investigating(仅诊断;本轮任务书禁止改 `useConversationScrollAnchor`,未修)
- 状态:investigating(已修复、本地真实浏览器验证通过;等待 Claude 验收与 staging 部署后真机复核)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`useConversationScrollAnchor` 的 `holdUnpin` 解除条件 × 生时校正 `.message-list` 的底部留白(`padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3))`)。
- 用户现象(本地复现):生时校正里发一句话,回答超过一屏时,本轮开头先被钉到顶部,回答一到视口就跳到最后一个字(BUG-930 想解决的现象)。
- 已确认事实:CDP 逐 100ms 采样:平滑滚动结束时本轮开头距顶 10px、`distanceFromBottom` = 94(≤ `conversationAnchorThreshold` 96),下一帧回答撑高后 `scrollTop` 直接到底。改动前(旧留白规则)表现完全相同(开头位置 −1323px,落底),与 BUG-1042 无关。普通咨询同一时刻距离约 198,不触发。
- 推测根因(未证实):钉顶后的静止距离等于该面底部留白 + 钉顶间距,校正面恰好 ≤ 96,`measure()` 把它当成「读者回到底部」,解除 `holdUnpin` 并把 `anchored` 翻回真,随后 ResizeObserver 贴底跟随。BUG-931 修的是留白坍塌导致的同一症状,这里留白正常但阈值判断仍然命中。
- 修复:未修。需单独任务书决定改阈值判定还是改该面的留白口径。
- 相关记录:BUG-930、BUG-931、BUG-1042。
- 影响面:`useConversationScrollAnchor` 的 `holdUnpin` 解除条件;生时校正面(钉顶后静止距底 94px)。普通咨询静止距底约 198px,未触发,但同一逻辑也适用。
- 用户现象:生时校正里发一句话,回答超过一屏时,本轮开头先被钉到顶部,回答一到视口就跳到最后一个字(BUG-930 想解决的现象)。
- 根因(已证实):钉顶后 `holdUnpin` 为真,`measure()` 在任何 scroll 事件里若 `distanceFromBottom ≤ 96` 就当作「读者回到底部」,解除 `holdUnpin` 并把 `anchored` 翻回真,随后 ResizeObserver 贴底跟随。钉顶平滑滚动自己产生的 scroll 事件落定时,校正面距底恰为 94(全视口留白 + `.message-list` 底部的跳到最新让位 56+12px − 钉顶间距),命中阈值。另一条同类路径:钉顶后任何向上的非用户滚动(内容收缩夹紧)也会解除 `holdUnpin`,再由几何距离翻回贴底。
- 修复:钉顶后只有读者自己的滚动能解除钉住:hook 在滚动容器上记 `wheel`、`touchmove`、按在容器本身(滚动条)的 `pointerdown`,在 window 上记文本框 / 按钮之外的滚动键(方向、翻页、Home/End、空格),`measure()` 仅当距上次这类手势 1 秒内(`conversationGestureWindowMs`)才解除 `holdUnpin`,之后照旧:回到底部 96px 内恢复跟随,上滑则停在读者的位置。`pinLatestTurn` 在钉顶时清掉手势时间戳,发问那一下点按 / 回车不会解除自己的钉顶。消息里的按钮点按(复制、展开思考)不算。阈值 96 与留白口径都不改。
- 验证:`conversation-scroll-anchor-lifecycle.test.tsx`:静止距底 94 且收到无手势 scroll 事件后回答撑长,仍钉在开头、`latestBelowFold` 为真(改前被拉到底);滚轮滚到底后恢复跟随;触摸上拉解除钉住但不跟随(BUG-930 语义);点按消息内按钮不算手势;滚动键过滤单测。无头 Chrome 151:375 宽改前开头位置 −1323px(落底),改后 10px 且 2 秒后仍是 10px、再插入 400px 仍是 10px(「跳到最新」出现);随后真实滚轮(CDP `mouseWheel`)滚到底 → 距底 0,再插入 400px 仍距底 0(恢复跟随);触摸拖动(CDP `dispatchTouchEvent`)同样到底后跟随。1280 宽改前 −482px,改后 10px,同样通过。截图 `docs/testing/scroll-anchor-hook-fixes-20260926/rectification-long-375-{before,after}.png`。
- 防复发:钉顶的解除只能来自用户手势;生命周期测试用 94px 静止距离锁住。
- 相关记录:BUG-930、BUG-931(同症状、留白坍塌触发)、BUG-1042、BUG-1043。
- 复发自:无(BUG-931 同症状、不同触发条件)。
- 修复版本:无。
- 修复版本:`codex/scroll-anchor-hook-fixes-20260926`,未部署。