Skip to content

fix(chat): 揭示前的定位只做一次,别在流式里反复重滚 - #534

Closed
eeee0717 wants to merge 2 commits into
refactor/message-list-hook-layersfrom
fix/ready-gate-restreams
Closed

fix(chat): 揭示前的定位只做一次,别在流式里反复重滚#534
eeee0717 wants to merge 2 commits into
refactor/message-list-hook-layersfrom
fix/ready-gate-restreams

Conversation

@eeee0717

@eeee0717 eeee0717 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

问题

新建话题里发出第一条消息时,列表位移会在 0 与 24/40px 之间来回弹四五次。续轮发送(进入已有话题再发)一次都不出现。

根因

ready gate 越界。

它的 effect 依赖 contentBaseHeight,而流式每来一个 chunk 内容高度就变一次。于是静默窗口反复重启、永远等不到完成,ready 报不出来,gate 也就在整段流式里每次重跑都真滚一次

场景 gate 滚动次数(三轮)
send-anchor 58 / 45 / 75
stream-scroll 49 / 25 / 42
follow-up-turn 0

这些滚动全落在「内容还不满一屏」的阶段,此时末端就是顶端,所以每次滚动都把 offset 拽回 0。

follow-up-turn 那个 0 是关键证据:它进话题时 ready 早就报过、gate 已关闭。这个不对称本身就说明 gate 越了界,也解释了为什么这个抖动此前只在第一条消息上看得见。

上一层 PR (#530) 给这个 scrollToEnd 补过交互锁,把 gesture-conflict 修到了 0。但交互锁只回答了「用户手上有动作时该不该滚」,没有回答「凭什么滚这么多次」。

修法

把 gate 的两半职责拆开:

  • 静默窗口继续守 —— 把冷 markdown 迟到的高度修正挡在遮罩后再揭示。这一半没有问题,没有改动。
  • 揭示前的定位只做一次 —— 它的语义本就是「首屏揭示前把列表放到正确位置」,是一次性事件。重跑只该重启窗口,不该再滚一次。此后位置维护归尾随状态机与 anchoredEndSpace

一行 didGateScrollRef。注意它只在真正滚动的分支置位:用户正在拖时跳过滚动的那条路径不消耗这一次,所以「拖动中跳过 → 松手后补滚」的既有行为不变。

验证

bench 三轮iPhone 17 Pro (layout-bench)):

指标 修复前 修复后
readyGate 程序化滚动 75 1
offset-reversal(send-anchor) 64 / 24 / 40 0 / 0 / 0
offset-reversal(stream-scroll) 48 / 40 / 24 0 / 0 / 0
viewport-blank(send-anchor) 38 / 29 / 35 0 / 6 / 0
viewport-blank(stream-scroll) 53 / 53 / 53 0 / 0 / 0

send-anchor 的 offset 轨迹从 24→0→40→0→24→0→24→0→16→80 变成严格单调的 16→80→104→173→213→233→253→273→293→353→413→433→453→513

冷进入的手工像素走查:gate 的原始用途是「冷进入已有长历史话题时挡住迟到的布局修正」,bench 三个场景都不覆盖,所以只能回到像素。录像抽帧后:

  1. 内容首帧可见(f0686)与最终帧(f0909)的行签名在 ±90px 内最佳偏移为 0,且 corr@0 == corr@best(0.9983 → 1.0000 单调上升)——是原地淡入,无位移。
  2. 随后向末端连滑四次,画面 0/1888 行变化、corr=1.0——落点就是内容末端。

两条都要有才算数:单看「没跳」不够,一直停在错的地方也不跳。

守卫测试做了负向验证:新增的 positions once before the reveal even while streaming restarts the settle window 在移除 !didGateScrollRef.current 后确实变红。

顺带记进文档的两个易误读指标

  • estimate-collapse 在前两个场景恒为 252px,不是漏修的跳动:全新话题里 userassistant-empty 都还没有尺寸均值样本,一起回退 estimatedItemSize=300,实测 56/48px。它发生在 t≈195ms、content=0、还没有过任何滚动事件的时刻,两行合计 104px 远小于 874px 视口——没有可滚动距离,修正不产生位移。
  • follow-up-turnviewport-blank 从 38/40/40 涨到 45/59/58,不是回归:峰值全落在 anchoring 相位且数值 ≈ endSpace/viewport,量的是预留空白本身;修复后钉顶到位更快(场景总时长 ~10s → ~8s),采样于是落在预留空白还没被填掉的更早时刻。三场景稳态仍全为 0%。但阈值是 60%,余量已从 20 个百分点缩到 1.4 个,文档里记了下次的判别方法。

Gate

packages:build + typecheck + test:app(310 套件 / 2897 测试全绿)+ lint(改动文件 0 警告)+ format + docs:check-links

🤖 Generated with Claude Code

@eeee0717 eeee0717 changed the title fix/ready gate restreams fix(chat): 揭示前的定位只做一次,别在流式里反复重滚 Aug 15, 2026
@eeee0717
eeee0717 force-pushed the refactor/message-list-hook-layers branch from b638942 to dc28605 Compare August 15, 2026 05:16
@eeee0717
eeee0717 force-pushed the fix/ready-gate-restreams branch from 0e2f500 to c014ce3 Compare August 15, 2026 05:16
eeee0717 and others added 2 commits August 15, 2026 15:05
新建话题里发第一条消息时,offset 会在 0 与 24/40 之间来回弹四五次。

根因是 ready-gate 越界。它的 effect 依赖 contentBaseHeight,而流式每来一个
chunk 内容高度就变一次,于是静默窗口反复重启、永不完成,ready 永远报不出来,
gate 也就在整段流式里每次重跑都真滚一次。实测三轮:send-anchor 场景 58/45/75
次,stream-scroll 场景 49/25/42 次。这些滚动全落在「内容还不满一屏」的阶段,
此时末端就是顶端,所以每次滚动都把 offset 拽回 0。

续轮发送一次都不出现——它进话题时 ready 早就报过、gate 已关闭。这个不对称本身
就是「gate 越界」的证据,也解释了为什么此前只在第一条消息上看得见。

gate 的两半职责其实是分开的:静默窗口负责把迟到的高度修正挡在遮罩后,这一半没
有问题;而「揭示前把列表放到正确位置」本就是一次性事件,重跑只该重启窗口、不该
再滚一次。位置维护此后归尾随状态机与 anchoredEndSpace。

bench 三轮对比(LAYOUT_BENCH_UDID=iPhone 17 Pro (layout-bench)):
- readyGate 程序化滚动 75 → 1
- send-anchor offset 轨迹 24→0→40→0→24→0→24→0→16→80
  变为 16→80→104→173→213→233→253→273→293→353→413→433→453→513(严格单调)
- offset-reversal 在 send-anchor / stream-scroll 三轮均为 0
- viewport-blank 0/6/0 → 0/0/0

冷进入已有长历史话题(gate 的原始用途,bench 未覆盖)手工像素走查:录像抽帧后
内容首帧可见(f0686)与最终帧(f0909)的行签名在 ±90px 内最佳偏移为 0 且
corr@0 == corr@best,是原地淡入无位移;随后向末端连滑四次画面 0/1888 行变化、
corr=1.0,确认落点即内容末端。无「第一次进入才跳」回归。

新增的守卫测试已做负向验证:移除 !didGateScrollRef.current 后该测试变红。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 新增「ready gate 越界」一节:交互锁只解决「用户在拖时别滚」,没解决「凭什么滚
  这么多次」;不对称(follow-up-turn 一次不出现)是根因证据;冷进入靠像素验,
  并说明为什么「没跳」单独不够——一直停在错的地方也不跳。
- 确定性表更新为修复后三轮,并补上两个容易被当成缺陷的数字的解释:
  estimate-collapse 恒 252px 是全新话题的估值冷启动(t≈195ms、content=0、
  两行合计 104px 远小于视口,无可滚动距离);follow-up-turn 的 viewport-blank
  从 40% 涨到 58% 是钉顶到位更快导致采样落在预留空白更早的时刻,稳态仍为 0%
  ——但阈值余量已从 20 个百分点缩到 1.4 个,记下下次的判别方法。
- 「不要用逐帧互相关」补上适用边界:该禁令针对「位移是多少」;问「有没有位移」
  时同报 corr@best 与 corr@0、两者相等才算数,周期纹理不会让真实零位移失去最优。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eeee0717
eeee0717 force-pushed the refactor/message-list-hook-layers branch from dc28605 to cbc5e04 Compare August 15, 2026 08:30
@eeee0717
eeee0717 force-pushed the fix/ready-gate-restreams branch from c014ce3 to 4947354 Compare August 15, 2026 08:30
@eeee0717

Copy link
Copy Markdown
Collaborator Author

已合并进 #549 一并 review——这几层都是 bench harness 之后对消息列表的优化,拆开反而不好读。分支保留,需要时可重开。

@eeee0717 eeee0717 closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant