Wework 消息滚动验证
Wework 桌面端聊天列表使用虚拟滚动。发送消息后,滚动逻辑必须持续覆盖用户消息和等待指示器的异步布局测量,直到列表稳定地位于底部,确保“正在思考”完整可见;流式回复增长时,如果用户仍在底部则继续跟随,否则保留用户当前视口。
实现滚动行为时需要遵守以下约束:
- 末尾流式消息使用虚拟列表的正常测量路径,避免重复调用测量接口破坏底部锚点。
- 新用户消息或等待指示器出现后,不能只执行一次同步滚动;必须在短暂的稳定窗口内跟随布局高度变化,再恢复最新用户回合的阅读位置保护。
- 用户主动向上滚动后,后续消息高度变化不得把用户视口拉回旧消息位置;即使用户上滚幅度尚未超过底部容差,也必须保留暂停,跟随逻辑不得将视口重新拽回底部。
- 滚动锚定权归应用自己:聊天滚动容器常开
overflow-anchor: none,禁止浏览器在内容高度变化时自己挪scrollTop。浏览器的锚定规则随“变化发生在视口上方还是下方”给出相反的结果,之前就是靠猜这件事才反复出现回弹和视口下滑。关掉它之后,“谁动了视口”只剩两个来源——读者自己滚,和这次布局变化——可以被精确测量。 - 还原只有一个决策点,并且只依赖可测量的事实:读者持有视口时,滚动区域在布局变化回调里取「锚点文字现在的屏幕位置」减去「采样时的位置」,再加上「这次窗口内
scrollTop的变化量」,把结果写回scrollTop(correction = ΔanchorOffset + ΔscrollTop)。纯用户滚动对锚点的位移恰好是-ΔscrollTop,两者相加为零,所以读者的滚动永远不会被吃掉;剩下的就是这次布局造成的位移,把它还回去即可。这个式子同时覆盖整列重排(打开文件面板后宽度变化)、视口上方单行重测、以及视觉下方正在流式变长的回答,且对所有滚动原点都成立(把scrollTop移动x在两种原点下都让文字移动-x)。回调在布局之后、绘制之前执行,因此修正不可见;锚点文字已经不在屏幕上时无法归因,此时不做任何猜测、直接重新采样。此外还有两件事必须成立:浏览器把偏移收进缩小后的范围(内容变矮时对scrollTop的 clamp)与高度变化一样属于布局的效果,不是读者的滚动,所以必须先把它归因给布局再测量读者滚动,否则之后每次修正都会去要一个已经不存在的偏移;只有修正真正写到位才重新采样,否则窗口会跨过这次修正。写入被 clamp 说明这次布局还没落到可用的范围上(关闭文件面板时那一帧把长回答测成十几万像素高就是这种情形),此时保留原采样、把已生效的部分记成本次写入,由下一次布局变化继续把文字补回原位,而不是把 clamp 的落点当成读者选定的位置。 - 不得为了让滚动条不动而伪造布局高度(例如把变矮的行按旧高度撑住):伪造出来的高度最终仍要释放,释放时照样推动滚动条和内容,并会在读者滑到该处时留下成片无法消失的空白。滚动条的位移只能靠更准的初始估算(让“重新测量”的差值本身变小)来减少。
- 用户视口锚点记录的是“用户选择的阅读位置”(消息 + 文本偏移 + 当时的
scrollTop)。它在用户意图(滚轮、指针、触摸、键盘)下和每次修正之后刷新;解析不出可见锚点时保留旧采样,不允许被空结果覆盖。 - 用户点击“回到底部”时,按钮的指针事件不得被滚动容器保留为手动滚动意图。该操作必须在短暂稳定窗口内持有底部跟随权,使虚拟列表测量或后续流式内容继续增高时仍能到达最新底部;底部跟随权必须一直持有到视口确实稳定位于底部,不得在固定时间点提前释放,否则内容继续增长时视口会停在中间位置。
- 重新打开长会话时,规范化的 turn 级会话视图必须以 executor transcript 返回的全局
messageIndex合并。分页快照中缺失的本地已停止回合只能回到它原本的位置,不能被追加到最新 AI 回复之后;没有索引的临时回合才使用消息时间戳作为次级排序依据。Provider transcript 分页仍由 Provider 自己管理:只能按稳定 ID 去重,不得把本地已停止回合合并进去,也不得绕过 Provider 分页。 - 空闲任务提交新目标时,目标与首条指令必须通过一次
runtime.tasks.send原子发送。Codex 的thread/goal/set会自动启动目标回合,executor 必须等待该回合,不能再额外调用turn/start,否则真实回合的输出会被错误归到旧位置,而空转的重复回合会在 180 秒后显示“消息生成失败”。 - 回归测试必须在真实 Electron 应用的长历史会话中发送消息,暂停模型响应,并检查滚动容器位于底部且等待指示器完整位于容器边界内。
流式渲染职责边界
流式正文和滚动跟随各自只能有一个调度源,不能同时叠加组件动画、直接滚动和延迟滚动定时器:
useBufferedStreamingText只负责把同一帧内的正文更新合并到下一动画帧。它不得维护慢于权威消息内容的逐字揭示队列;下一帧必须提交当时最新的完整前缀,后台帧不可用时必须由短超时兜底。Streamdown继续使用streaming模式做增量 Markdown 解析;非尾部 Markdown 块继续使用现有窗口化和冻结逻辑,不能因动画重建整棵消息树。ScrollableMessageArea的流式自动跟随只由一个阻尼弹簧驱动。ResizeObserver、消息追加和运行态变化只能请求这个弹簧继续追踪最新底部,不能同时调用流式直跳或稳定滚动定时器。- 弹簧持有底部跟随权时,虚拟列表只负责测量消息高度和换算底部原点坐标,不得直接改写列表高度或滚动位置。只有用户已向上滚动并暂停自动跟随时,虚拟列表才可同步补偿屏外流式消息的增高,以保持阅读锚点。
- 用户主动向上滚动、turn navigation 暂停或自动滚动挂起时,立即取消弹簧。非流式布局稳定、历史恢复和用户点击“回到底部”仍使用各自原有路径,不与流式弹簧并发。
prefers-reduced-motion下跳过弹簧运动,直接定位到底部。
运行桌面回归测试:
cd wework
pnpm e2e:desktop:streaming-text
该场景使用真实后端请求,并由桌面 E2E harness 提供确定性的 SSE 响应流。测试会在服务端开始输出前暂停响应,以稳定覆盖用户消息先渲染、等待指示器和虚拟列表随后完成测量的竞态;不得通过跳过、mock 前端请求或放宽底部与可见性断言来掩盖滚动回归。
流式渲染改动还必须覆盖帧内合并后立即显示最新完整前缀、Unicode 内容、弹簧收敛、用户上滚暂停和回到底部恢复。真实 Electron 场景需要保留用户上滚前后的锚点截图,并确认后续流式追加不会移动该锚点。回到底部回归必须发送真实的指针按下与点击事件,并在点击后继续追加足以改变虚拟列表高度的内容,最终断言距离底部不超过滚动容差。
消息顺序回归还会先停止一轮响应,再发送 26 个新回合使旧回合离开默认 transcript 分页,切换到其他任务后重新打开原任务,并确认底部显示最新 AI 回复而不是“已停止”提示。
目标续跑回归会先确认同一个 runtime.tasks.send 请求同时携带目标和首条指令,再读取真实 executor 日志,确认目标提交只等待 thread/goal/set 自动启动的一个回合、没有额外 turn/start,并确认持续输出和完成后都没有出现错误卡片。