feat(chat): 用户消息从输入框飞到钉顶落点 - #544
Closed
eeee0717 wants to merge 7 commits into
Closed
Conversation
入场的可见位移改由行自身的 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>
eeee0717
force-pushed
the
feat/animate-first-anchor
branch
from
August 15, 2026 12:55
fc79382 to
4fa3ca5
Compare
Collaborator
Author
|
已合并进 #549 一并 review——这几层都是 bench harness 之后对消息列表的优化,拆开反而不好读。分支保留,需要时可重开。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
这条 PR 做什么
把「刚发送的用户消息」的入场统一成一条动画:从输入框上缘飞到钉顶落点。位移由行自身的
transform 承担,钉顶滚动改成瞬时——两者叠加会走双倍距离。
为什么要改
原来第一条消息没有入场动画,后续消息有。根因不在动画代码里:入场靠的是钉顶滚动动画,
而新建话题内容不足一屏、可滚动距离恒为 0,滚动动画本来就演不出任何东西。为此曾加过一套
animateFirstEnteringMessage+ 分阶段释放锚点空间的机制,专门去制造可滚动距离。改成 transform 之后动画不再依赖可滚动距离,第一条和后续走完全同一条路径,那套机制整体作废,
连同它在
PaintingComposer(唯一消费者)的接线一起删掉——留着会让绘画那条路走双倍位移。可见行程怎么拆
行的可见行程 616px 由两段分担,加起来恒等:
不能让两段都走全程(双倍距离),也不能为了避开双倍就把滚动改成瞬时——那会让整屏旧内容一帧
切走(实测相邻两帧模板相关度 1.000→0.239,参照实现是 ~150ms 斜坡)。待滚距离取自
getState()而不是组件里的 React state:后者滞后于助手占位行挂载,实测算出 202px 而真实滚动 305px。
响应式
没有任何写死的距离:
四个量全是运行时布局值,随机型、字号、输入框行数与键盘状态自适应。曲线是临界阻尼弹簧,
它的收敛时间与行程无关——同一条曲线在任何屏幕上都是同样的手感,这正是它能安全共享的理由,
所以放进了
packages/ui的motion.ts(那个文件的既有说法就是「曲线是设计 token」)。自查:模拟器算出 616px,参照实现在 iPhone 17 Pro 上实测 629/666px。
不照抄参照实现的那一跳
参照实现(ChatGPT)用的是独立于列表的覆盖层,落地时把位置交接给真实行。实测飞行中上滑
打断会交接错位、一帧硬跳 455pt。行内 transform 没有交接这一步:用户中途拖动时行跟着内容
走、残余偏移继续收敛,结构上不会产生那一跳。
验证
bench 三轮(
iPhone 17 Pro (layout-bench)),9 个场景全绿零违规:slide-in-flight起飞距离slide-in-flight滚动分担offset-reversal峰值viewport-blank峰值estimate-collapse修正offset-reversal的 40/22/22 是原生滚动动画的收尾过冲(+29 出去、−15 回来),阈值 100px。它曾一度是 0/0/0——那版把钉顶滚动改成了瞬时,判据更绿但屏幕更硬:用 15px 的落位抖动
换掉了 306px 的一帧硬切。单向的一帧硬位移目前没有判据覆盖,已写进文档的已知盲区,连同
「为什么不能直接补一条单帧位移上限」(94 条历史轨迹标定显示遮罩下的 gate 定位滚动合法地
一次跳 2000-2250px)。
逐帧录像取证(harness 验不了曲线形状——轨迹活在 UI 线程,逐帧回抛会拖慢它本身):
24 个采样点 RMS 2.91px、最大残差 6.8px,全程单调无过冲。同一段录像证伪了上线前挂着的
「LegendList 会裁掉被 transform 移出行盒的内容」这个风险:气泡在 595px 行程上始终完整可见。
拆成两段之后又录了一次。此时可见轨迹是「原生滚动缓动 + 弹簧」的和,不再是纯弹簧,所以只验
单调无过冲、落点与拆分前逐像素一致(top=136 vs 137)、背景恢复斜坡(14 帧 / 187ms,逐帧
2/7/11/16/19/22/24/25/27/27/25px)。
顺带修的两处 harness 缺陷
位移搬到 transform 上之后滚动轨迹会平得像什么都没发生,harness 对它本来就是为了量的那段
运动完全失明——补了
slideIn探针与slide-in-flight判据,上线前在两条人工改坏的轨迹上验红。
查
viewport-blank余量时发现同一个缺陷被同一条判据漏掉了两次:contentSize不含anchoredEndSpace,「视口越过内容末端」把钉顶刻意预留的那段也算了进去——稳态就吃掉 59%,阈值 60% 只剩 1 个百分点,判据实际在量设计本身。
情况恰好是它唯一沉默的情况。
82 条历史轨迹重放验证:健康带 0–10%(据此把阈值收到 0.2),唯一报红的是键盘补丁前的
exp2/follow-up-turn,峰值 310px——正是那次补丁修掉的量,65/93 个样本超限、从 t=5233ms持续到收尾。改之前它是全绿的。
Gate
packages:build/typecheck/test:app(309 套件 2894 用例全过)/lint(0 error)/format/docs:check-links。