Skip to content

fix(chat): 发消息时的跳动、白屏与闪烁 - #549

Open
eeee0717 wants to merge 17 commits into
refactor/message-list-hook-layersfrom
feat/chat-layout-stability
Open

fix(chat): 发消息时的跳动、白屏与闪烁#549
eeee0717 wants to merge 17 commits into
refactor/message-list-hook-layersfrom
feat/chat-layout-stability

Conversation

@eeee0717

Copy link
Copy Markdown
Collaborator

这条 PR 做什么

合并原先的 #534 / #544 / #546 / #547 / #548 五层,方便一次读完。底下四层(#528 探针与假
provider、#529 按钮闪烁、#530 harness、#531 hook 分层)保持独立,本 PR 基于 #531

主题是一条线:发出一条消息时,界面不该跳、不该闪、不该白。17 个提交按关注点分组,
按 commit 读能保住每一步的取证。

五组改动

1. 揭示前的定位只做一次(原 #534

ready gate 的 effect 依赖 contentBaseHeight,流式每来一个 chunk 就重跑一次,于是整段流式
里每次都真滚一次。新建话题里内容还不满一屏,每次滚动都把 offset 拽回 0,表现为 offset 在
0 与 24/40 之间来回弹四五次。

修法是把 gate 的两半职责拆开:静默窗口继续守,「揭示前把列表放到正确位置」是一次性事件,
重跑只该重启窗口。readyGate 程序化滚动 75 → 1,offset 轨迹从 24→0→40→0→24→0… 变成
严格单调的 16→80→104→…→513

2. 用户消息从输入框飞到钉顶落点(原 #544

原来第一条消息没有入场动画,后续消息有。根因不在动画代码:入场靠的是钉顶滚动动画,
而新建话题内容不足一屏、可滚动距离恒为 0,滚动动画演不出任何东西。

改成由行自身的 transform 承担位移后,第一条和后续走完全同一条路径,那套专门去制造可滚动
距离的机制(animateFirstEnteringMessage + 分阶段释放锚点空间)整体作废并删除。

可见行程 616px 由两段分担、加起来恒等:钉顶滚动走「把旧内容让开」需要的那段(新话题 0px、
已有话题 306px),行的 transform 走剩下的。两段都走全程会是双倍距离;而为了避开双倍就把滚动
改成瞬时,会让整屏旧内容一帧切走(实测相邻两帧模板相关度 1.000→0.239)。

3. 新话题的第一条消息不再导航到空界面(原 #546

open-topic 排在 createUserMessageWithPlaceholders 之前,聊天界面挂载时它要展示的那一轮
压根还不存在。用户看到的是:发送 → 白屏 → 转圈 → 消息才弹出来。

第一版修法是错的,留在这里当反面记录:我去调空状态覆盖层的不透明度规则,等于给一个不该
存在的窗口画更好的盖布,还引入了回归——续轮发送时空状态整块盖在已有对话之上,实测 6 帧 ≈
80ms、相关度峰值 0.807。

对的修法是让导航发生时目的地已经有内容:消息 id 在写库之前先铸出来(newMessageId(),同
$defaultFn 的 uuidv7 生成器),据此构造乐观 Message 发布进快照,然后才 emit 导航,最后按
同一批 id 落库。id 相等是硬前提——mergeMessagesWithOverlay 只按 id 全等去重、未命中即
append。遮罩、转圈、空状态覆盖层与 NewTopicScreen 整个屏随之删除。

改前 改后
发送 → 气泡可见 ~1.32s ~700ms
中间序列 空状态 → 遮罩+转圈 → 空白 → 气泡 空状态 → 空白 → 气泡
续轮命中空状态文案 6 帧 / corr 0.807 0 帧

顺带把 invalidate-topicsemitAndWait 挪进 onTurnPublished:它是一次 refetch,等的是
一份此刻没人在看的数据,却同时卡住导航前的窗口和 LegendList 的 onReady

输入框也提到了分支之上(ChatScreen 现在是「header + 可换的中间那块 + 常驻 composer」),
顺带修掉一个既有隐性缺陷:原来两屏切换会让 ComposerProvider 整棵子树重挂,handleSend
失败时的草稿回滚写进空气。

4. 待生成占位与思考行等高 + 状态行收敛(原 #547

行高由内容撑,而两个状态的内容不一样高:待生成占位只有 20px 的圆点,思考行是
max(圆点 20, 文字行高 24, chevron 16)。切换那一帧整条助手消息跳 4px(探针实测 48 → 52)。

给占位补一行不可见的 text-base 文字撑高。不能写死 24px:行高随字号档位变
--ui-text-base--line-height),写死会在别的档位重新失配。

顺势把三处状态行(待生成占位、思考中、工具调用)收敛成 MessageStatusRow——它们占的是同一个
槽位,用户看到的就是它们相互切换,所以必须等高,那 4px 正是三份副本漂开的结果。
ToolPartTrigger 里四处 isDanger ? … : isWarning ? … : …(9 个 className 字面量)也一并
换成一张颜色查表。

5. header 助手按钮不再消失(原 #548

topicId 一出现就硬切数据源,而 useTopic 还在 fetch,assistant 在那几百毫秒里是 null。
iOS 上 Stack.Toolbar 的 children 从 2 掉到 1,两个按钮一起走原生重建

逐帧读 header 区暗像素:改前 437 → 261 → 110(几乎全空)→ 398 → 520,跨约 600ms;
改后 461 → 464,601 帧零变化。

验证

  • bench 三轮全场景 0 违规,确定性表已随改动换代(见文档 diff)
  • 全量 gate:pnpm typecheck 绿、pnpm test:app 309 套件 / 2901 测试全过、lint 0 error
  • 逐帧取证一律读 ffprobe 的真实 pts_time(模拟器录制是 VFR,按固定 fps 反推会把几百
    毫秒的停顿算成几十毫秒)

已知边界

  • 工具行没有真机取证:bench 三个场景都不含工具调用,第 4 组对它的改动靠结构等价 +
    ToolParts 单测覆盖。
  • send-anchorslide-in-flight 装填→开火从恒 0ms 变成 318–645ms,含义换了代:列表
    现在是带着这一轮挂载的,装填发生在挂载那一刻、开火要等第一次布局与 ready gate。逐帧看气泡
    真正停在起飞位只有 ~76ms,不能读成用户看到的停顿。文档里写了怎么判它是否回归。
  • follow-up-turnestimate-collapse 从 4 变 27 不是回归,是判据的精度问题(探针在
    尺寸没变时不发事件,判据于是把第二次真实增长当成首次实测)。两条修法记在文档里。
  • 不在本轮范围:失败时留下的空话题行、ChatInputselectedAssistantId 硬切、launch 后
    那段 260–350ms 主线程空档(全部 39 条历史轨迹都有,非本次引入)。

eeee0717 and others added 17 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>
入场的可见位移改由行自身的 transform 提供,钉顶滚动一律瞬时(两者叠加会走双倍距离)。
这样第一条消息和后续消息终于走同一条路径:transform 不依赖可滚动距离,而新建话题里内容
不足一屏、可滚动距离恒为 0,正是「第一条没有入场动画」的根因。

起飞距离=视口高 − 输入框占位 − 钉顶落点偏移,三个量都是运行时布局值,随机型/字号/输入框
行数/键盘状态自适应;曲线是临界阻尼弹簧,时间常数与距离无关,因此同一份参数在任何屏幕上
观感一致。参数取自对参照实现的逐帧拟合:两次独立飞行都是 τ=63.5ms,R²>0.9999。

顺带删掉的:
- 首锚 staging(alignItemsAtEnd + 延迟补空白)。它唯一的用途就是给第一条制造可滚动距离,
  被 transform 取代后没有存在理由;`animateFirstEnteringMessage` 随之移除,绘画那边也统一
  走同一条入场路径。
- slideInGate。「一生只播一次」原本要靠它守,现在偏移量由列表持有、行只是读,滚出滚回读到
  的就是当下的值,重播在结构上不可能发生。
- 入场淡入。它当初的理由是「钉顶瞬时定位、消息一出现就在终点,再叠纵向运动会读成抖一下」,
  这个前提已经不成立;逐帧取证参照实现也确认气泡从第一帧起就是满色满宽。

飞行中的气泡会画在上一条回复之上——这不是缺陷而是效果的来源,参照实现同样如此。它用独立
覆盖层做,落地时交接给真实行,实测飞行中被上滑打断会交接错位、硬跳 455pt;行内 transform
没有交接这一步,用户中途拖动时行跟着内容走、残余偏移继续收敛,结构上不会有那一跳。
发送后的可见位移搬到了行自身的 transform 上,滚动轨迹因此平得像什么都没发生——harness 对它
本来就是为了量的那段运动完全失明。补 slideIn 探针(装填/开火/落定)与 slide-in-flight 判据。

起飞距离跟着 launch 上报而不是 arm:探针由假模型在构造时打开,那晚于 pendingUserMessage
出现,首轮的 arm 事件收不到,只读 arm 的判据在 send-anchor 里结构性全绿。

判据上线前在两条人工改坏的轨迹上验红:起飞距离置 0、删掉全部 launch 事件,各报 1 条违规。

曲线形状 harness 验不了(逐帧回抛会拖慢它本身),改用录像取证并写进文档:临界阻尼拟合
τ=61.0ms(设计 63.5ms)、D=609px(探针 616px),24 点 RMS 2.91px、单调无过冲。同一段录像
证伪了「LegendList 裁掉 transform 溢出」的上线前风险。

顺带订正 useMessageSlideInFlight 的一处注释:飞行路径是钉顶预留的空白,途中并没有内容可遮,
只有回复已开始流式而行还没收敛完的最后几十毫秒会压住回复首行。原文照搬了 ChatGPT 覆盖层的
行为,逐帧录像不支持。
同一个缺陷被同一条判据漏了两次:

一、量错了对象。LegendList 的 contentSize 不含 anchoredEndSpace(实测空话题 content=357、
viewport=874、endSpace=517,严格满足 874-357=517),「视口越过内容末端」于是把钉顶刻意预留
的那段也算了进去。稳态就吃掉 59%,阈值 60% 只剩 1 个百分点——判据在量设计本身。改成先扣
endSpace,它问的才是该问的那句:有没有滚到既没内容、也不是预留空白的地方去。

二、最严重的情况反而不报。违规只在越界「跌回阈值以下」时结算,越界一直持续到轨迹结束就
永远结算不了。补一次收尾结算。

82 条历史轨迹重放验证:健康带 0-10%(据此把阈值从 0.6 收到 0.2),唯一报红的是键盘补丁前的
exp2/follow-up-turn——峰值 310px,正是那次补丁修掉的量,65/93 个样本超限、从 t=5233ms 持续
到收尾。改之前它全绿。补丁后的同场景轨迹(patchdet1-3)全部 3-7%。
入场位移改由行的 transform 承担、钉顶滚动改成瞬时之后,没有动画滚动就没有收尾回弹,
follow-up-turn 的 40/24/22 噪声带整栏变 0。补 slide-in-flight 两行:起飞距离三场景三轮
恒为 616px(纯布局推导,与话题内容无关),装填→开火在新话题恒为 0ms、另两场景 ~105ms
是 ready-gate 静默窗口。
入场行的 transform 落地后,钉顶滚动被一并改成了瞬时,理由是「和 transform 叠加会走双倍
距离」。理由没错,处理方式错了:那一下把**背景**的动画整个砍掉了。

钉顶滚动搬的不是入场行,是旧内容——新消息要贴到视口顶部,原先屏上的一切都得让位滑走。
录像逐帧确认改成瞬时后这段是一刀切:相邻两帧(13ms)间整屏内容消失,模板相关度 1.000→0.239。
参照实现是 ~150ms 的斜坡,本项目此前是 ~240ms(22-30 笔、每笔 11-29px)。

改成两段分担:滚动照常带动画走它那一段,弹簧只走「总行程 − 这次滚动」。两段相加恒等于
616px,新话题里滚动分担为 0、弹簧独扛全程,所以第一条和后续的可见路程仍然一模一样——这次
重构的核心收益不受影响。

待滚距离必须问列表自己(getState().contentLength − scrollLength − scroll),不能拿组件里的
React state 重建:contentBaseHeight 是上一次 onContentSizeChange 落下的值,助手占位行挂载
带来的增高常常还没回灌,实测据此算出 202px 而真实滚动 305px,行会多飞 103px。改用列表自报
的几何后探针上报 306px,与实测逐像素吻合。

验证(三轮 + 一轮带录像,全部 0 违规):背景恢复成 14 帧 / 187ms 的斜坡(逐帧 2/7/11/16/19/
22/24/25/27/27/25px);气泡单调无过冲、落点 top=136 与拆分前的 137 一致;maxScrollAssistPx
三场景三轮 0/0/306 完全确定。offset-reversal 回到 40/22/22——那是原生滚动动画的收尾过冲
(+29 出去、−15 回来),用 15px 的落位抖动换掉 306px 的硬切。

这个缺陷全套判据零违规,而且改成瞬时后 offset-reversal 反而从 40 变成 0:**判据更绿、屏幕
更硬**。单向的一帧硬位移目前没有判据覆盖,已写进已知盲区,并附上为什么不能直接补一条
「单帧位移上限」——94 条历史轨迹标定显示遮罩下的 gate 定位滚动合法地一次跳 2000-2250px。
发送后「思考中」圆点在用户气泡刚起飞时就出现在它下方,读起来顺序是颠倒的
——回复先于提问显形。改成跟着同一条弹簧的落位进度淡入。

实现要点:飞行状态里多带一条 `landingProgress`,与 `offset` 同一时刻、同一
弹簧配置起跑,所以「行到了没有」不用再猜一个延迟常量。助手行只改 opacity
**不改布局**:它必须从第一帧起就占住自己的高度,否则 LegendList 算不出锚点
下方的预留空白,`onReady` 等不到「锚点下方全部测量完毕」,钉顶落点会跟着错。

录像实测(同一口径:用户气泡 752→136 的落位百分比 vs 助手行像素数):
修改前助手行在落位 0% 就已渲染(159 px),修改后到 99% 才开始淡入。
对比胶片见 artifacts/layout-bench/follower/assistant-fade-before-after.png。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
两段状态在 bench 里都测不到:首片延迟只有 120ms,占位行一闪而过;而所有场景用的
`mixed` 夹具压根没有思考块。于是「思考块出现时的高度 settle」从来没被覆盖过,录像
里也看不出占位是什么时候出现的。

指令加两个时长段:`bench:<fixture>[@<rate>][+<pendingMs>[+<reasoningMs>]]`。给的是
**时长**而不是速率——这两段在界面上是两种独立状态,要被看见的正是它们各自停留了多久;
正文那段关心的才是生长速率,仍用 `@<rate>`。默认值不变,日常开发用这个 provider 不
会被迫等两秒,场景控制仍然全落在 harness 一侧。

`mixed` 补上思考块:它是所有场景的默认夹具,而真实的一轮是「待生成 → 思考 → 正文」
三段。这改变了基准输入,历史运行的绝对数字不再可比。

回放不再走 `simulateReadableStream`:它只认一个全局 chunkDelay,分不出三段。换成
按分片自带延迟的小回放器。

场景相应改成 `bench:mixed@40+2000+2000`,并把 stream-scroll 的上滑推到正文流式期间
——占位与思考块两段内容还没超出视口,那时候滑一下没有尾随滚动可对抗。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
发送后界面会白一下、转个圈,loading 结束消息才弹出来。上一版(已被这次提交取代)
去改遮罩与空状态,等于给一个不该存在的窗口画遮盖物,还引入了回归:续轮发送时
`hasMessages` 为真而装填那一刻 `landingProgress` 是 0,`interpolate(0, [0,0.35], [1,0])`
= 1,空状态整块不透明地盖在已有对话之上 6 帧(≈80ms,模板匹配相关度 0.807)。

真正的根因是时序:`open-topic` 排在 `createUserMessageWithPlaceholders` 之前,聊天
界面挂载时它要展示的那一轮压根还不存在。遮罩、转圈、空状态三样都是这个洞的糊墙纸。

把窗口关掉:

- 消息 id 改由调用方分配(`MessageService.newMessageId`,仍是 uuidv7)。助手占位的
  id 透传本来就写好了,只是 runtime 依赖契约把类型截断了;用户消息的 id 放在
  `dto` **旁边**而不是里面——`CreateMessageDto` 是 HTTP 边界的 strictObject,远端调用
  方不该能指定 id。
- `runTopicTurn` 在落库**之前**按这批 id 构造乐观 `Message`,走现有的
  `publishTurnSnapshot(..., 'reserving')` 发布,然后才让调用方导航。id 相等是硬前提:
  落库后回来的行同一身份,`mergeMessagesWithOverlay` 就地替换而不是追加。
  导航用 `emit` 而非 `emitAndWait`——后者不吞订阅者异常,导航失败不该毒化整轮。
- 四个 `runTopicTurn` 调用点一视同仁,已有话题里续轮发送的气泡也不再等落库。

顺带把输入框提到分支之上:`ChatScreen` 收敛为「header + 可换的 body + 常驻 composer」,
`NewTopicScreen` 随之删除。输入框全程不卸载,`ComposerSurface` 发送失败时的草稿回滚
从此写进活着的 provider(此前写进空气,用户只看到 toast)。

`MessageList` 的起飞距离加窗口高度兜底:改完时序后列表会带着数据挂载,而
`viewportHeight` 是 onLayout 回填的 state、首帧为 0,不兜底气泡会先在落点画一帧再跳
回起飞点。ready-gate 仍只认实测值。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
发布顺序改到落库之前以后,首轮入场行的装填与开火都发生在 `streamText` 之前,而探针原本
由假模型在被构造时(`streamText` 内)才 arm——于是 `slide-in-flight` 判据对进程里的第一条
消息完全失明:`launches=0 maxTravelPx=0`,`settles=1` 却还在,说明动画确实跑了,只是没人
记下来。第二、三个场景看着正常,只是因为 harness 不重启 app,探针从第一个场景一直armed。

改成由「只有 harness 会做的事」触发:它带着 `layout-bench-assistant` 深链接进聊天屏。
这比第一条消息早好几秒,与任何一轮的时序都无关。助手 id 因此上移到探针模块,种子数据从
那里取,两边不会再各写一份。

假模型里的 arm 保留为兜底(手动选基准模型、没走深链接的情形),但它不再是主路径。

实测:send-anchor 从 `launches=0 maxTravelPx=0` 回到 `launches=1 maxTravelPx=616`。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
新话题的第一条消息里,`invalidate-topics` 原本 `emitAndWait` 在 `runTopicTurn`
之前。那是一次 refetch,等的是一份此刻没人在看的数据——话题列表在后台,用户
正盯着聊天界面。它却同时卡住两处:导航前的窗口,以及 LegendList 的 `onReady`。

改成和 `open-topic` 一起挂在 `onTurnPublished` 上,两个都用 `emit`:订阅者抛错
不该毒化一整轮发送。

实测(layout-bench 钉死模拟器,各连跑三轮):

- send-anchor 的 `maxArmToLaunchMs` 773/819/749 → **318/324/645**。区间不重叠,
  但改后这个数不再恒定,方差落在话题创建与首次落库上。
- 逐帧取证(真实 pts):发送 → 气泡可见 1.32s → **~700ms**,中途无遮罩、无转圈、
  无屏幕互换;`bubble_top` 748→532→…→189 单调上行,无落点回跳。

`does not open a new topic when aborted after topic creation` 原本拿这次
awaited 失效当中断同步点,改挂到附件落盘上——那是话题创建之后、乐观发布之前
仅剩的 await 点,而导航如今就挂在那次发布上。

Co-Authored-By: Claude <noreply@anthropic.com>
导航时序这一条是判据一条都没红、由用户看录像提出来的。盲点很结构性:探针挂在
列表上,列表没挂载的那段时间完全失明,而所有判据量的都是已渲染内容的位移。

一并换代确定性表(新话题的第一条消息现在带着这一轮挂载,绝对值不与旧表可比):

- `offset-reversal` 三场景归零,但**归零有两种走法**,判别靠同时读总行程仍是 616
- `send-anchor` 的「装填→开火」从恒 0ms 变成 318/324/645ms,是表里唯一不确定的
  一格,含义也换了;逐帧看气泡真正停在起飞位只有 ~76ms,不能读成用户看到的停顿
- 那个孤零零的 `viewport-blank` 5% 是采样噪声,另两轮为 0

录像取证补两条方法:问「元素有没有出现过」要写成**共现**判据(命中帧里同时含真实
气泡的帧数为 0),否则「界面本来就是空的」那些帧会被算进去;时间戳一律读 pts_time,
模拟器录制是 VFR,按固定 fps 反推会把几百毫秒的停顿算成几十毫秒。

Co-Authored-By: Claude <noreply@anthropic.com>
助手行的高度由内容撑,而两个状态的内容不一样高:待生成占位只有圆点(20px),
思考状态行是 `max(圆点 20, 文字行高 24, chevron 16)` = 24px。切换那一帧整条
助手消息在锚点正下方跳 4px(探针实测 48px → 52px)。

给占位补一行不可见的 `text-base` 文字撑高。**不能写死 24px**:行高随字号档位变
(`--ui-text-base--line-height`),写死会在别的档位重新失配,只有同款文字才跟得住。
NBSP 而不是空格,因为格式化器会把纯空格的 JSX 子节点折掉。

顺带修好了圆点自己的 2px 竖向跳动——`items-center` 在 48px 和 52px 的行里居中
位置差 2px,而 `AssistantMessageRow` 的注释一直声称「圆点位置连续」。

实测(三轮全场景,0 违规):

- 探针里 `48 → 52` 那条 itemSize 事件整条消失,首次实测直接是 52
- `estimate-collapse` 248/248/248(= 300 − 52),follow-up-turn 27/27/27

follow-up-turn 那格从 4 变 27 **不是回归**:判据按 key 认「首次实测」,而探针挂在
`onItemSizeChanged` 上、尺寸没变就不发事件。估准了(52 估 52 实测)那次于是静默,
判据看到的第一个事件其实是后面「正文第一行到达」的 52→79。改之前的 4px 同样不是
估值失准,而是这次修掉的那 4px 跳动。判据的精度问题,已写进文档。

Co-Authored-By: Claude <noreply@anthropic.com>
待生成占位、思考中、工具调用三处各写了一份
`flex-row items-center gap-2 py-0.5`,交互的两处还各自复制了
`active:opacity-60` 与 `accessibilityRole="button"`。

它们不是碰巧长得像:三行占的是**同一个槽位**——「第一段内容将要出现的位置」——
用户看到的就是它们相互切换,所以必须等高。上一个提交修的 4px 跳动正是三份副本
漂开的结果(占位那份没有文字,只靠 20px 圆点撑高)。把不变量收敛进组件,下次加
第四种状态行时它是免费的。

`StatusRowTextFloor` 单独导出、不塞进 `MessageStatusRow` 内部:另外两处自己的
`text-base` 文字已经撑到位,无条件多塞一个只会白占 4px 宽、挤掉 `numberOfLines={1}`
的截断余量。谁再加不含文字的状态行,搜这个名字就能找到理由。

工具行是结构等价替换(同一个 Pressable、同一串 className、testID/onPress/
accessibilityLabel 原样透传),靠 ToolParts 单测覆盖;bench 三场景都不含工具调用,
这一处没有真机取证。

验证:`send-anchor` 首次实测仍是 `300 → 52`,`48 → 52` 那条跳变事件依然不存在;
0 违规;全量 309 套件 / 2900 测试通过。

Co-Authored-By: Claude <noreply@anthropic.com>
`ToolPartTrigger` 里四个位置各写了一遍
`isDanger ? … : isWarning ? … : …`,展开成 9 个 className 字面量。而三档之间**只有
颜色 token 不同**,布局类完全一样——调一次间距要同步改三份副本,副本漂开还看不出来。

换成一张 `statusTone → 颜色类` 的表,四处拼接。9 个字面量 + 2 个布尔变量变成
3 个 token + 4 个模板。

`text-base` 与颜色类的相对顺序因此统一成「颜色在末尾」。这是安全的:改之前同一个
组件里 `size-5 text-destructive`(颜色在后)与 `min-w-0 flex-1 text-destructive
text-base`(颜色在前)两种顺序就并存着,若 uniwind 对同前缀做「后者胜」的消解,
早该表现成「图标红、文字不红」。

这张表没有测试覆盖——改前那 9 个字面量同样没有,现有断言打在传入的 `statusTone`
prop 上。按仓库测试瘦身时定的「真机能否可靠发现」判据,查表写错的形态是「失败的
工具调用不显示红色」,一眼可见,所以不补渲染测试。

Co-Authored-By: Claude <noreply@anthropic.com>
`topicId` 一出现就硬切数据源(`topicId ? topic.data?.assistantId : assistantId`),
而 `useTopic` 还在 fetch,于是 assistant 在那几百毫秒里是 null。iOS 上
`Stack.Toolbar` 的 children 数量因此从 2 掉到 1,**两个按钮一起走原生重建**——不是只
少一个,是整组淡出再淡入。

逐帧实测(导航窗口内 header 区暗像素):

- 改前:437 → 261 → 110(几乎全空)→ 398 → 520,跨约 600ms
- 改后:461 → 464,601 帧零变化

改成解析不出来时回落到 URL 上的 assistantId。回落值不会是别的助手:`open-topic` 走
`setParams({ topicId })`,assistantId 原样留在 URL 上且就是这个话题的助手;从话题列表
进来那条路带的 params 只有 topicId,回落到 undefined,与从前一致。

Co-Authored-By: Claude <noreply@anthropic.com>
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