OpenProgram / Web / 转录

聊天转录跟随

作者:engineering 状态:Web 渲染器已实现 范围:Web 渲染器(App 与浏览器)

发送消息并不能可靠地把读者带到最新一轮。四个重叠的滚动写入者对“底部”是什么、谁可以移动视口意见不一(遗留 scrollToBottom、未使用的 useStickToBottom、现场 useChatAreaStick、以及 loadSessionHistoryWindow)。本页把它们收成一份由 React 持有的跟随策略:一种附着/脱离状态、一个“在底部”判定、一张跟随触发表、以及一个以 padding 边缘定义的 latest。聊天 handler 和历史加载器不得把 latest 钉到任意视口上。

已采纳契约的交互预览

该视口完全本地,不发网络请求。它实现的是统一策略,不是当前产品行为。可以灌入长历史、向上翻、发送、模拟流式,并使用“跳到最新”。在最新窗口上,发送瞬间贴底并附着。正在显示更早窗口时,发送保持 pending、保留 Jump,latest 加载后再贴底。流式增长只在附着时跟随。“跳到最新”用原生平滑滚动重新附着(请求减少动效时改为瞬间)。

已附着 最新窗口 发送 = 瞬间贴底
随便问…然后发送,或在转录区按 Enter
策略日志:就绪。灌入历史,向上翻,然后发送。

键盘:Tab 到滚动区或按钮;在滚动区按 Enter 发送;“跳到最新”是真实按钮。呼吸垫为 max(25vh, composer + 24)。latest 是 padding 边缘,因此最后一条气泡落在输入框上方的可见带里,而不是被输入框挡住。Jump 在平滑行程落定前保持可见;滚动器上的指针或滚轮取消行程。发送仍然瞬间隐藏 Jump。本预览尚未覆盖本页新增的 generation 生命周期、异步取消及 Jump 动态目标完成验证,不作为这些行为的契约实现证据。本视口不模拟欢迎页 overflow、peer overlay 松弛、latest 窗口加载失败,或真实更早历史页(更早页控件只是一个标志)。

当前实现与已核实缺口

useChatAreaStick 负责主视图和分栏的内容定位、发送跟随及 Jump。chat-scroll.ts 提供滚动计算和带 generation 的记录。useHistoryWindow 与历史加载器负责有界分页、各视口的锚点恢复。旧跟随实现已移除。

发送身份与准入

接受的普通发送、直接函数调用及运行中 queue/steer 提交,按捕获的 session 和 scroller 发布记录。加载中或失败的附件在创建队列、清除草稿和发布记录之前拒绝准入。完成后的记录按设计继续可读;后来拒绝的提交必须保留原记录及其 generation。

首次激活与延迟布局

没有已存像素位置时,首次激活创建独立 generation,并在延迟布局后定位最新。短时间返回恢复阅读状态,跟随底部或已过期的视口显示最新消息。用户操作取消待执行跟随。已接受且待执行的欢迎页发送保留到对应视图就绪。

记录底部状态的会话恢复

每个视口同时保存滚动偏移和是否跟随最新消息。返回原本跟随最新消息的视口时,应在延迟加载和排版完成后继续显示新的底部;手动上翻阅读历史的视口则恢复已保存的偏移和消息锚点。缺少时效信息的旧记录显示最新消息。用户操作取消待执行的跟随;主栏与分栏使用独立的键。异步会话加载不得用旧像素覆盖底部定位。重载时,位置没有变化则保留其跟随状态。

恢复位置遵循统一的生命周期规则。新启动的 App 或 Web 文档始终显示最新消息,忽略其他运行周期保存的状态。同一运行周期内,离开不足一小时后返回会话,恢复阅读状态;满一小时后返回则显示最新消息。应用失去焦点满一小时后重新激活,同样显示最新消息。不会用定时器移动正在查看的聊天。切走、隐藏或卸载时记录离开时间,即使用户近期没有滚动。显示最新消息时清除过期历史锚点,并使用现有可取消的最新消息加载流程。存储包含偏移、底部状态、时间和运行周期标识;缺少时间依据的旧记录按首次打开处理。一小时时限只在一处定义。

首次空转录区随后异步加载消息时也保留跟随最新的意图。消息提交早于 ResizeObserver 回调,layout 按原有跟随状态定位新的底部,不把高度变化视为阅读操作。600 ms 增长抑制仅从实际指针、滚轮或滚动按键输入开始计算。

旧历史与首次激活

初次激活已加载的旧窗口时,请求最新窗口,在验证最新响应并发布之前保持 detached,不提前定位旧窗口。检验这种激活的浏览器 fixture 必须提供响应,才能要求最新定位。检验旧窗口手动阅读的 fixture 则应明确建立已存视口,否则会意外启动另一项激活请求。

历史恢复职责

加载器使用每个注册视口各自的锚点和 key 恢复位置,不执行最新定位。历史页面变化时,hook 保留手动阅读和取消行为。 会话重载不再安排第二次像素恢复:缓存窗口中的偏移可能超过较短的最新分页,被浏览器限制到底部,覆盖原本正确的阅读锚点。

欢迎页首次发送

主视图在欢迎页解除滚动限制后重试当前已接受发送。分栏发送不使用该行为。

分栏视口独立

主视图和分栏使用不同 scroller key。接受的 queue/steer 只跟随提交视图;附件拒绝保留两个视口。

已核实的浏览器 fixture 缺口

两个旧窗口浏览器 fixture 没有响应自动首次激活的最新请求,却等待非零初始位置。四个 queue/steer 用例假设加载中附件拒绝后没有记录,但首次激活已经创建了完成后的记录。这些断言与激活及记录生命周期契约冲突。仅修正具体 fixture:明确建立旧历史阅读位置,比较拒绝准入前后的准确记录。不改变首次激活最新请求、延迟增长定位、手动取消或发送语义;保留现有 Node 激活回归的验收含义。

受控浏览器测试使用生产 hook 和隔离后端响应,不证明已安装 App 或真实 provider 验收。fixture 修复和独立审核证据单独记录。

官方参考语料与框架对照

对照的是产品规则,不是像素克隆。缺失的能力保持缺失。

表面向上翻时发送流式 / agent 增长重新附着控件会话 / 历史此处缺失(除非另注,保持缺失)
ChatGPT web 发送是“带我到最新”手势。新用户轮出现在活动带里。 读者在活动边缘时跟随。向上翻脱离。滚回边缘附近重新附着。 脱离时出现向下箭头。流式时可显示该控件的 live 变体。 打开对话瞄准 latest;失败时有恢复位置的缺陷报告。 OpenProgram 不引入虚拟列表库。不自动跟随每一次 DAG 节点展开。
Claude.ai / Claude Code web 提交把用户轮送进视口。阅读旧内容会被发送打断,不会被模型继续生成打断。 在活动边缘跟随流式。手动向上翻保持位置。 不在活动边缘时提供跳到最新。 切换对话恢复该对话,而不是全局 stick 标志。 没有全局 window stick 标志。OpenProgram 只在名字上删掉了 window.__stickListenerInstalled;模块级标志仍在。
Cursor chat / Composer 发送把新提示保持在输入框邻近带。 钉住时跟随生成;检查器式向上翻脱离。 落后于尾部时显式跳转。 按线程。不作为协议来源。 不引入 Cursor 式 message-scroller 库。本切片不做“把上一轮钉在顶部”的第二模式。
本仓库中的 Codex / ChatGPT Atlas 笔记 仓库内没有 Atlas 转录跟随规格。此处 Codex 材料覆盖队列/转向(send-queue-reliability.html),不是滚动物理。 — — — 不编造 Atlas 滚动契约。队列/转向仍是独立主题。
OpenProgram CLI ScrollBox scrollToBottom() 把 stickyScroll = true(手势持有的标志),然后渲染器钉住。 仅在 sticky 时位置跟随。scrollTo / scrollBy 清除 sticky。Sticky 不依赖布局 epsilon。 命令式 scrollToBottom 重新设置 sticky。 仅 TUI。 不在实现范围内。采纳具名 attached 标志这一想法,不采纳 Yoga/Ink 代码。
shadcn MessageScroller(先例,不是我们交付的产品) 新用户轮可用靠近顶部的 turn-anchor,并露出上一轮的一点内容。 autoScroll 仅在底部时运行。 落后时出现 MessageScrollerButton。 — 作为库以及作为第二套“把上一轮钉在顶部”的模式均拒绝。OpenProgram 的垫已经在输入框上方留出呼吸空间。

ChatGPT、Claude 与 Cursor 的共同规则:发送是隐式跳到最新;模型 token 不是。OpenProgram 的 resolveChatScrollTop 已经陈述该规则。Web 视图没有用单一所有者执行它。

采用、修改与拒绝

采用

一份策略模块,位于 apps/web/lib/chat/chat-scroll.ts。每个滚动器一个 attached 布尔。发送 / 队列 drain / 欢迎页第一条 = take-latest,瞬时贴底。流式只在已附着且位于最新历史窗口时 pin。跳到最新仍是显式平滑重新附着。首次激活定位最新,随后激活恢复已存位置,不创建聊天消息。桌面 App 使用同一套 web 渲染器。

修改

Take-latest 是带 generation 的记录,键为 session id 加上提交滚动器的 chatKey,不是 consume-once 布尔,也不单独靠 lastRole。lastRole === "user" 在发送路径记号进入同一合并列车之前仍是回退。指针 600ms 抑制只用于会改变尺寸的转录区内点击,从不用于 take-latest 或跳转。Peer overlay 与 pad 是分开的输入;不要把 --peer-composer-h 当成 --main-composer-height。resolveChatScrollTop 仍决定是否移动;调用方随后赋 latestScrollTop(Jump 已用的命名钳位 padding 边缘)。带 history.after 的发送在 latest 到达前保持 pending;过期页上 attached 保持 false。历史加载器不执行 latest 定位;窗口锚点恢复保留在 history-viewport helper,具体归属见下文。

拒绝

第四份 stick 文件。以最后一条 + 16px 作为跟随目标。把发送做成第二条平滑动画。WS handler 调用滚动器。全局 window / 模块 stickToBottom。把粘滞写到磁盘。虚拟列表库。自动跟随每一次 DAG 展开。重写发送/ack 协议。把 CLI ScrollBox 打进 web 包。并行的“把上一轮钉在顶部”模式。

“底部”指什么

latest 是滚动器的 padding 边缘。latestScrollTop = max(0, scrollHeight - clientHeight) 是 Jump 已经使用、并且在 clientHeight > 0 时把 scrollTop = scrollHeight 钳到同一位置的命名形式。主栏垫为 max(25vh, overlay + 24)(peer 垫是已发布的 --peer-composer-h = overlay + 24),最后相关内容落在输入框上方的可见带。剩余松弛是 chatAtBottomSlack(pad, overlay) = max(0, pad - overlay) + 8。overlay 是浮动 composer 盒子,不是垫的 CSS 变量。

拒绝最后一条 + 16px,因为它相对滚动器可见底边测量,而那条边在叠层下面,并且忽略了跳转已经使用的垫。pin 到 padding 边缘却不用松弛量会让跳转按钮直到再多滚约 25vh 才出现;该缺陷已在 isChatAtBottom 修好,予以保留。

目标架构

1
Composer 提交、队列排出、斜杠提交或 Jump 写入跟随手势。WebSocket 帧不滚动。历史加载器不得把 latest pin 到 registered.keys().next() 的 scrollHeight 上。数据发布后,匹配 hook 执行发送 snap 或 Jump smooth;窗口替换按各视口恢复,见 History 恢复的执行归属。
2
useChatAreaStick 是 DOM 施加器与状态机(stuckRef、jumpingRef、lastPointerRef、pending latest 加载)。MessageList 与 peer pane 传入滚动器、列、overlay 读取器和滚动器 chatKey。
3
原语在 chat-scroll.ts(latestScrollTop、take-latest 记录、overlay vs pad、snapToLatest、resolveChatScrollTop 作为是否移动)。hook 施加它们。没有 decideFollow 动作枚举。
4
列上的 ResizeObserver 只在已附着、位于最新窗口、且没有进行中的 Jump 时 pin。Take-latest 仅在已经位于最新窗口时,即使在 600ms 窗口内也强制 stuckRef 并 snap。当 after 置位时,take-latest 为 pending:保持脱离,保留 Jump,不对过期页 snap。600ms 仍挡住无关的转录区内增长。
5
遗留 scrollToBottom 先变成有文档的空操作垫片,再连同调用方删除。useStickToBottom 删除。同一次统一中,加载器停止把 latest 钉到任意第一个视口。
公开入口手势附着运动历史窗口
Composer 提交(Enter / 发送),包括欢迎页第一条take-latest在最新窗口上:立即为 true。当 after 置位:pending,保持脱离,保留 Jump。latest 加载成功后:true。失败或 !isCurrent:保持脱离。见 发送闩。在最新窗口上:layout 看到记录,强制 stuckRef = true(忽略 600ms 和当前 isChatAtBottom),现在以及本轮下一次列 resize 时 snapToLatest。当 after 置位:pending,保持脱离,保留 Jump。600ms 仍挡住无关转录区内增长。瞬间贴底,不是平滑。Jump 在 snap 时隐藏,pending 时不隐藏。欢迎页:主壳在 welcomeVisible / #welcome-mount 子节点清空时重试一次。若 after 置位,不对过期页 snap。用 Jump 风格的 isCurrent 异步回调 await loadSessionHistoryWindow(..., "latest"),不要在 useLayoutEffect 里 await。加载器不得把 latest pin 到 registered.keys().next();数据发布后由匹配 hook 执行仍有效的发送操作。
真正调用 sendChatMessage 的队列排出take-latest(与提交相同)仅提交滚动器,与提交相同瞬间贴底(同一闩)与提交相同。只入队不是发送;排出才是。
不走 sendChatMessage 的 composer 斜杠 / 内建提交(use-chat-submit.ts 的 dispatchFunction)在提交点 take-latest与提交相同同一闩。WS local_command handler 不滚动、不记 note。若 local_command 行在没有提交 note 时到达(另一客户端),仅在已附着时跟随。
跳到最新(主栏:portal 到 #chatView;peer:现有标题栏按钮,无 portal、无 live 条)jumplatest 上屏后为 true;按钮留到 settle 再淡出(jumpingRef / JUMP_LATEST_FADE_MS)原生平滑滚动;prefers-reduced-motion: reduce 时瞬间。滚动器上的指针/滚轮取消行程。等待 latest,然后 animateJumpToLatest。现有 isCurrent 取消保留。加载器不得把 latest pin 到任意第一个视口;数据发布后匹配 hook 直接开始仍有效的平滑 Jump,不先 snap。
#chatArea / 对等滚动器:滚轮、指针、键盘user-scroll当且仅当 isChatAtBottom 且没有 after无分页不变。向上翻脱离。滚进松弛量重新附着。
流式 / 工具 / KaTeX / 回收 / 垫高 / 输入框 resizegrowth不变仅在已附着时 pin 到 latestScrollTop设置了 after 时不 pin。
会话切换(chatKey 变化)restore定位最新后保持跟随返回时恢复已存位置,首次打开定位最新不是发送。仅当新 session 就是该草稿被采纳的身份时,才迁移 pending take-latest;切到另一个已有会话建立独立的激活意图。历史自动加载 / around 锚点仍由 useHistoryWindow 负责。
不是 take-latest/jump 的历史窗口加载window有 after 则为 false,否则几何判定加载器不得把 latest pin 到 registered.keys().next() 的 scrollHeight。flushSync 之后对每一个已注册区域做 older/around 的 history-viewport 恢复。更早/更新的页不得当作 latest 来 pin。恢复是策略对 scrollTop 的写入,不是加载器把 latest pin 上去。
WS chat_response / compaction / status / follow-up / local_command 帧无—handler 不调用滚动、不记 take-latest行进入 store;视图只经 growth + attached 跟随,除非 composer 提交已经记过 take-latest。

发送闩序列

  1. sendChatMessage(或斜杠提交)在适用时调用 appendLocalUserTurn,然后 noteTakeLatest({ sessionId, scrollerKey, turnSeed })。sendChatMessage 内部默认:scrollerKey = background ? "peer:" + sessionId : sessionId。交互提交可以显式传入 scrollerKey。排出时不要读取现场 activeChatKey(那是聚焦会话,未必是正在排出的会话)。队列排出因已带 sessionId 和 background 调用 sendChatMessage 而继承;send-queue.ts 保持不变。
  2. 匹配 hook 的 layout effect 看到 generation 新于 lastAppliedGeneration 且 scrollerKey 等于本实例 chatKey 的记录。其他 pane 不闩。记录不在读取时删除(Strict Mode 与两个 hook 实例)。
  3. 若 history.after 置位:不设 attached,不 snap,保留 Jump。启动与 Jump 相同的异步 isCurrent await。layout effect 不得 await。
  4. 已在最新窗口(或 latest 加载成功)时:无论 600ms 指针窗口和当前 isChatAtBottom,设 stuckRef = true,立刻 snapToLatest。
  5. 本轮下一次列 ResizeObserver 回调也 snap(迟到的行高)。该 pin 因 take-latest generation 仍当前而忽略 600ms。无关的转录区内增长仍遵守 600ms。
  6. 仅主壳:订阅 welcomeVisible 或观察 #welcome-mount 子节点。overflow 锁解开时,若同一 take-latest generation 仍当前则重试 snap 一次。Peer 发送使用 background: true,跳过欢迎页。
  7. 追加助手占位的 ack 不脱离:attached 已闩上,不从 lastRole 推导。

手势记录的生命周期与取消

note 标识一次发送,不代表永久滚动许可。按 (sessionId, scrollerKey) 独立保存,使用 peekTakeLatest(sessionId, scrollerKey) 读取;同 session 的主栏与 peer 发送不能互相覆盖。发布 note 必须通知匹配的 view,包括没有新增转录行的 slash submit;不能只在无关 render 中读取可变 Map。turnSeed 标识已接受的乐观用户行,不要求等于 ack 之后的最后一行。

  1. 每个活动操作捕获 session id、scroller key、generation、view interaction epoch。异步完成、下一次 resize 重试、welcome 重试、动画回调写入前都验证这四项。较新发送替换本 scroller 的旧操作;Jump 替换 pending send;发送替换 Jump,并先停止原生动画再瞬间定位。
  2. 转录区中的滚轮、触摸/指针滚动意图、滚动按键,在后续 resize 或网络回调之前使 pending 操作失效,并清除强制 resize/welcome 重试。实际滚动后按几何位置确定 attached;点击执行控件仍保留现有的短暂增高抑制。程序触发的 scroll 事件本身不算取消。发送后用户上翻,迟到增长不得强制再附着。
  3. 切 key 或真正卸载使旧 view 的操作失效。短时间返回 chat 恢复阅读状态,过期返回创建新的最新消息定位操作,不重放已完成或已取消的发送 note。记录保持可读,不在读取时删除;明确维护完成/取消状态与各 view 的 applied-generation。Strict Mode effect cleanup 清理该次 effect 的资源;setup 必须恢复仍有效的 pending 操作,不新增逻辑发送、不丢重试、不遗留请求。开始 latest 请求时不能直接把 generation 标为完成。删除会话或丢掉草稿时清除该 session 的 note。已完成且没有活动视口的空闲记录设上限;仍在等待的发送记录保留。释放视口后在本轮结束再执行该上限,以便同一轮重挂可以再次 retain。
  4. 欢迎页首次发送可能早于 transcript 可见。只保留这次仍 pending 的交接,直到匹配 view 就绪;挂载时初始化订阅基线不能丢掉它。可见和 layout 就绪后重试一次,后续用户手势或离开 session 则取消。现有 provisional 到真实 session 的绑定应保留 pending 操作身份,不新增发送、不重放已完成操作。绑定依据是该草稿被采纳后的 session 身份,不是「任意 local_* 切到任意非草稿」。ack 替换 message id 后,不要求 turnSeed 继续等于最后一行 id。
  5. 已有 older/around 请求进行中时,即使当前页暂时没有 after,发送也要等待确认 latest 窗口。以 history.after || history.loading 进入异步协调,然后验证返回窗口和操作身份。取消/失败不遗留强制重试、不隐藏 Jump;后续新手势仍可成功,旧完成回调不得把新操作设为 detached 或清除它。

一次 next-resize 重试属于该操作,不属于整个助手回合。用完后恢复 attached 与正常手势规则;generation 未变不代表无限期绕过 600ms。每个 hook 管理自己的资源和清理,不增加轮询计时器或持久化 attached。

内容变化时的 Jump 完成条件

latest 加载成功后,Jump 直接从保留位置开始平滑滚动,禁止先 snap。Jump 期间 resize 记录目标变化,不瞬间 pin。原生滚动停止时重新计算 latestScrollTop;目标变动则结束原动画,瞬间补齐到当前 padding 边,并验证位置。只有位置验证通过且操作仍有效,才隐藏 Jump。reduced-motion 从开始就瞬间定位,使用同样的最终验证。

超时仅用于结束异常等待,不证明已经到达。超时后,仍有效的操作先停止原生运动,再补齐到当前目标、验证并完成。取消既要停止回调,也要停止浏览器动画;只删除 scrollend listener 不算取消。已取消操作不得补齐。后续增长恢复普通 attached pin。定位允许 2px 几何误差,不用更大的再附着 slack 代替 Jump 到达验证。

History 恢复的执行归属

loadSessionHistoryWindow 协调请求并发布行及窗口元数据,不执行 latest 定位,不检查 send/jump note。只有 useChatAreaStick 在 latest 成功后选择并执行发送 snap 或 Jump smooth。older/newer/around 窗口替换继续由现有 history-viewport helper 恢复锚点:更新前分别捕获每个 registered area 的 anchor、oldTop、oldHeight;flushSync 后仅当该 area 仍注册在同一 session 且 chatKey 一致时才恢复。曾经注册、后来失去注册的 area 不得恢复。未注册的 #chatArea 回退只用于捕获时本来就没有注册、且节点仍是当前主栏滚动器的情况。节点仍连接但已被复用时不得写入。高度缓存也使用各自 chatKey,不能复用第一视口的 anchor 或高度差。

窗口替换恢复是明确保留的滚动写入,不是另一套跟随策略。它不执行 latest snap,也不能覆盖较新的活动跟随操作。同 session 的主栏与 peer 共享历史数据,替换行可能影响两者;非请求方在原锚点仍存在时保留其可见位置。淘汰导致锚点消失时沿用现有有界回退,并在验收证据中说明;scroller 隔离不承诺独立历史数据集。

信任边界、数据流、失败

兼容与排除

API 与数据模型变化

扩展 chat-scroll.ts。不要加第四个 stick 模块。原语加上带 generation 的记录;useChatAreaStick 仍是状态机。示意表面(名字可在审查时收紧;行为不得改):

export function latestScrollTop(area: ScrollMetrics): number;
// Jump 已经使用的命名钳位:max(0, scrollHeight - clientHeight).

export type TakeLatestNote = {
  sessionId: string;
  scrollerKey: string; // 默认: background ? `peer:${sessionId}` : sessionId
  turnSeed: string;
  generation: number;  // 单调;禁止读取即删
};

export function noteTakeLatest(note: Omit<TakeLatestNote, "generation">): TakeLatestNote;
export function peekTakeLatest(sessionId: string, scrollerKey: string): TakeLatestNote | null;
// 没有 consumeTakeLatest。每个 hook 用实例 lastAppliedGeneration 比较。
// Strict Mode 重跑是幂等的。

export function readComposerOverlay(area: HTMLElement | null): number;
// 主栏: --main-composer-height(仅 overlay)。
// Peer: composer 根 offsetHeight,或 (--peer-composer-h - 24)。
// 绝不要把 --peer-composer-h 当 overlay。

export function snapToLatest(area: HTMLElement): void;

resolveChatScrollTop 仍是是否移动函数,可以继续返回 scrollHeight 作为哨兵。跟随决定之后调用方赋 latestScrollTop(area)。触发表中的手势名是文档,不是函数参数。

键空间:send 知道 sessionId 和 background。sendChatMessage 默认 scrollerKey 为 background ? "peer:" + sessionId : sessionId(主栏 chatKey 在显示该会话时等于 session id;peer pane 一律用 peer: 前缀)。交互提交可以显式传入 scrollerKey。排出时不要读取现场 activeChatKey。hook 仅当 note.scrollerKey === chatKey 时闩上。不同会话的两个 pane 不会共享闩。同一会话的两个 pane 目前共享 peer:${sessionId}(与行高缓存同一碰撞);本切片不加 pane 实例 id。公共入口测试:未聚焦 peer composer 对会话 Y 发送、另一 pane 显示会话 X 时,只附着 Y。

useChatAreaStick 仍返回 { detached, jumpToLatest }。发送路径记号落地前,lastRole === "user" 仍是有文档的回退,以保持发送跟随为绿。丢掉 lastRole 和更新 check-chat-ui.mjs 与 noteTakeLatest 同一合并。

加载器不得任意 pin latest。数据发布后发送/Jump 定位归 hook;窗口替换由 history-viewport helper 分别恢复各视口,具体规则见 History 恢复的执行归属。

遗留:

/** @deprecated React MessageList owns follow. No-op. */
export function scrollToBottom(_opts?: { force?: boolean }): void {}

然后删除它和每一个调用方。删除 useStickToBottom。conversations.ts 不再强制滚动之后删除 runtimeState._skipScrollToBottom。不要从 chat-handlers.ts 调用任何滚动器。

验收标准

公开入口前置可观察结果
Composer 提交在最新窗口上翻得很远;按 Enter 或发送乐观用户气泡(若仍是 last-role user,含 pending 指示)在 store 追加后 0ms 处于输入框上方可见带。瞬间贴底,不是平滑。attached 变为 true。Jump 在 snap 时隐藏,不是之前。
输入框提交已经附着同样贴到最新;随后流式保持钉住。
欢迎页第一条欢迎页可见;overflow 锁定欢迎页在 0ms 隐藏;锁解开后第一条用户行在活动带。滚动不得停在已解锁空垫高的 0。
队列 drain某条排队行经 sendChatMessage 真正发出(另一会话可能正被聚焦)与手动发送相同的 take-latest。scrollerKey 由 sessionId 和 background 默认,不读现场 activeChatKey。只入队不 drain 不移动视口。send-queue.ts 保持不变。
跳到最新在更早窗口上处于脱离等待最新页,然后平滑滚动(减少动效时瞬间)。Jump 留到 settle 再淡出。滚动器上的指针/滚轮取消行程。主栏用 #chatView portal;peer 用现有标题栏按钮。
#chatArea 滚动向上滚到最后一条钻到输入框下脱离;跳转可见;之后的助手/工具增量不移动 scrollTop。
#chatArea 滚动滚回松弛量内重新附着;跳转隐藏;随后增高会 pin。
流式增量已附着;KaTeX / 回收 / token 增高pin 到 latest。数学渲染走同一 pin 路径。
没有 take-latest 的 agent/system 行已脱离scrollTop 不变。compaction 与 local_command 帧不得调用滚动器。Composer 斜杠/内建提交在提交点记 take-latest。
会话切换已存位置,首次打开为最新消息返回时恢复已存位置,首次打开定位最新,不创建消息。
历史窗口在 pages[sid].after 置位时发送不对过期页 snap。Jump 保留。attached 在 latest 加载成功前保持 false,然后 snap+附着。失败或 !isCurrent:保持脱离,Jump 可见。加载器不得把 latest pin 到 registered.keys().next()。
Peer session pane从未聚焦 peer composer(会话 Y)发送,另一 pane 显示会话 X只有 Y 的滚动器附着并 snap。X 不动。松弛:overlay = composer 根,pad = h+24,slack = 24+8。Peer Jump 是标题栏按钮(无 portal、无 live 条)。聚焦壳欢迎/运行标志不被触碰(background: true)。
发送后的 ack助手占位成为最后一行不脱离。流式继续 pin。
滚动后 600ms 内发送指针在区域上,然后输入框提交take-latest 仍贴底并 pin。600ms 抑制不适用。
遗留辅助统一后 grep除待删空操作垫片外,没有 scrollToBottom 的生产调用方。useStickToBottom 消失。#chatArea 上没有模块级 stickToBottom 监听器。

需要新增的 RED 测试(公共入口,不是实现细节):take-latest 记录在 ack 和 Strict Mode 双 layout 后仍有效;发送 + history.after 在 latest 到达前保持脱离;晚到的行高之后仍 pin;600ms 不挡住发送;scrollToBottom 未被使用;加载器不得把 latest pin 到 registered.keys().next();排出使用 background ? "peer:" + sessionId : sessionId 而不是现场 activeChatKey;peer overlay/pad slack 24+8;从 peer Y 发送不 snap pane X;欢迎 overflow 后一次重试;减少动效时 Jump 是瞬间。在落地 noteTakeLatest 的 PR 之前保留 lastRole === "user" 断言。Peer pad/overlay 单元:pad h+24,overlay h ⇒ slack 24+8。

会话切换与文件摘要加载

首次打开且没有已存视口的会话定位最新消息,异步文件卡片增高后仍保持最新位置;短时间返回恢复阅读状态,过期或重启后显示最新消息。保存的像素位置不能代表最新消息。首次打开旧历史窗口时,通过现有可取消的跟随操作加载最新窗口;用户滚轮或键盘操作取消跟随,历史分页继续保留锚点。主视图与分栏视图使用各自的滚动标识。

旧文件修改摘要仅在接近可视区域时加载,成功后保留在现有会话消息存储中,重新挂载立即显示,包括已确认无文件的结果。错误允许重试,取消和过期响应不更新其他会话。历史操作仍重新预检,撤回权限仍由服务端校验;摘要描述历史内容,不授权撤回。

验收使用实际 React 组件和滚动 hook:重复挂载不重复请求摘要;首次打开后延迟增高仍定位最新,已打开会话切换或隐藏返回恢复原位置;用户操作取消跟随;无已存位置时首次打开旧历史窗口加载最新,已有历史位置不强制跳转,过期响应隔离。不修改后端协议、消息顺序、持久历史或模型行为。

新视口、过期视口和跟随底部的视口显示最新;短时间返回历史阅读视口时恢复位置。实际 React 回归覆盖首次延迟增高、返回恢复位置、用户取消、过期响应和摘要保留。独立审查、完整前端检查与已安装 App 证据分别记录在任务报告中。

实施交接说明

本节是唯一实施计划。状态:已规划,尚无实现或审查通过证据。改代码前读本页、AGENTS.md、tests/README.md 与 openprogram 工作流。保留无关 dirty 文件;从不可变基线建立干净 codex/ worktree,只转入这两份已确认设计及现有导航条目。精确基线和候选 commit 记入忽略的 reports 台账,不写进设计正文。

由一名实施负责人顺序执行 A、B、C、D。每个代码阶段先由非作者做契约审查,再做独立质量审查;确认问题修复并复审后才能进入下一阶段。审查者不编辑受跟踪文件。B 不拆成可独立合并的 PR。A 可单独合;B 必须包含 legacy no-op 和全部 B 用例;C 再删 no-op。本次规划不授权远端 push/merge。

阶段主要文件(仓库相对路径)交付要求
Aapps/web/lib/chat/chat-scroll.ts
apps/web/tests/chat/chat-scroll-follow.test.mjs
apps/web/tests/chat/jump-latest.test.mjs
原语、按键可订阅 note、精确定位、可取消的 Jump helper。B 之前保留现有调用兼容。
Bapps/web/components/chat/messages/use-chat-area-stick.ts
apps/web/components/chat/messages/message-list.tsx
apps/web/components/chat/peer-session-pane.tsx
apps/web/components/chat/composer/submit/send-chat-message.ts
apps/web/components/chat/composer/submit/use-chat-submit.ts
apps/web/lib/runtime-bridge/session-history-loader.ts
apps/web/lib/chat/history-viewport.ts
apps/web/components/chat/messages/use-history-window.ts
apps/web/lib/runtime-bridge/helpers.ts
完整接入:note 发布/订阅、生命周期、latest 加载、各视口恢复、overlay reader、welcome 交接、Jump 完成、legacy no-op 并移除全局 listener。同一候选中删除 lastRole 推断。
Capps/web/lib/runtime-bridge/helpers.ts
apps/web/lib/runtime-bridge/chat-handlers.ts
apps/web/lib/runtime-bridge/conversations.ts
apps/web/lib/format-utils/scroll.ts
apps/web/lib/runtime-bridge/state.ts
删除 no-op 调用/import、死代码 useStickToBottom、废弃 _skipScrollToBottom 声明与赋值。先查全引用,保留显式定位 helper。
DBoth chat-transcript-follow HTML pages
apps/web/tests/chat/send-chat-message-latency.test.mjs
apps/web/tests/desktop/self-update-scroll.test.mjs
apps/web/scripts/chat/check-chat-ui.mjs
tests/e2e/web/test_chat_history_interactions.py
tests/e2e/web/test_chat_transcript_follow.py
组合候选验证与文档证据。这里列出的测试随 A/B/C 对应改动加入,不推迟到 D。D 不补写遗漏的核心功能。

B 开始时用 CodeGraph 列全 legacy 调用者和现有 provisional 绑定路径;仅在契约确实需要时把查到的确切文件补入 brief。不重做 ack 或队列准入。默认 key 由保存的 session/background 得到,send-queue.ts 不变;本次不引入超出这两个默认值的自定义 key。Peer overlay 读取 composer root offsetHeight,pad 沿用现有 reader;不加 CSS 变量或 pane instance id。

验收矩阵

A 使用纯函数/共享边界测试。B 的生命周期、layout、动画用真实浏览器中的生产 React hook,实际公开提交/队列入口及可控 WebSocket 响应。扩展现有 history interaction harness,不把预览策略复制成测试实现。断言 scrollTop、行/overlay 几何、按钮可见性、请求顺序及清理。显式运行 StrictMode;源码字符串断言不能证明时序。表中拟新增测试文件在所属阶段创建。

ID阶段必须验证的结果
F01A空/短/溢出几何;主栏 slack 与 peer h+24 对 h 得到 32px。
F02A/B同 session 主栏/peer note 独立;generation 递增;不更新行也触发订阅。
F03B上翻后分别 Enter/Send:socket 接受后、ack 前用户行在 overlay 上方;placeholder 与流式继续跟随。
F04B滚动后 600ms 内发送,迟到列增高补齐;发送后在迟到增高前上翻,不再强制贴底。
F05B脱离后流式/工具/数学增高保持位置;滚回 slack 再附着;执行行展开保留抑制。
F06Bafter 页发送不写旧页,pending 保留 Jump;成功贴底,失败/取消保持脱离。
F07B当前无 after 但正在加载 older/around 时发送;协调旧请求后加载 latest;旧响应不能覆盖新手势。
F08BStrictMode 首发及 pending latest cleanup/setup:不丢意图、不重复逻辑发送、不遗留 pending。
F09B发送、脱离、切出切回;真正卸载/重挂恢复已存位置,不重放。请求期间切换不移动新 chat。
F10Btranscript 未就绪时欢迎页首发与 provisional ack 绑定;有效重试一次,取消后不写。
F11Bpeer Y 发送、pane X 脱离:只移动 Y。同 session 主栏/peer note 独立;共用 peer key 的 pane 明确共同响应。
F12B仅入队不移动;焦点变化后真正 drain 仍用保存的 session/background。socket 关闭/抛错或重复首发不产生新 note。
F13Bslash/builtin 提交不依赖立即新增行也跟随;脱离时远端 local_command/compaction/status 不跟随。
F14B旧窗口 Jump 加载后实际平滑滚动,不先 snap;验证完成前按钮保留。
F15BJump 期间增高、动画结束前停止增高:最终距当前 latest 不超过 2px 再藏按钮;另测无 scrollend 的超时分支。
F16B指针/滚轮/滚动键取消 pending 或原生 Jump,无后续动画/补齐;Jump 中发送瞬间生效;减少动效时瞬间定位。
F17B两个位置不同的注册区域分别恢复 older/newer/around 锚点及回退高度差;latest 无加载器任意 pin。
F18C/D无 legacy 全局跟随 listener 或生产 scrollToBottom/useStickToBottom 引用;显式定位与自更新持久化保护仍有效。

命令与完成条件

以下命令从仓库根执行。按 CONTRIBUTING.md 安装锁定的 dev/browser 依赖与 Chromium,使用 workspace Node 依赖。A 先运行两个原语测试文件;B/C 运行下列相关选集。对应修复前通过生产边界记录实际失败基线,再验证 GREEN。新 API 尚不存在时不把 import 失败当作有效 RED;对应行为缺陷仍需 B 的基线证据。

node --no-warnings --experimental-strip-types --test apps/web/tests/chat/chat-scroll-follow.test.mjs apps/web/tests/chat/jump-latest.test.mjs apps/web/tests/chat/send-chat-message-latency.test.mjs apps/web/tests/desktop/self-update-scroll.test.mjs
npm --prefix apps/web run check:chat-ui
npm --prefix apps/web run check:provisional-send
npm --prefix apps/web run check:send-queue
uv run --locked --extra dev --extra browser python -m pytest -q -m browser tests/e2e/web/test_chat_transcript_follow.py tests/e2e/web/test_chat_history_interactions.py tests/e2e/web/test_automatic_chat_history.py tests/e2e/web/test_scalable_chat_history.py

最终代码候选通过两轮审查后完整执行下列清单一次。此后受跟踪代码/测试/依赖变动,需相关复审并重跑完整清单。缺依赖或已验证基线失败仍记 failed/pending,不用较小测试集代替。CLI 不变;仅 desktop 源码改变时追加 desktop checks。

npm --prefix apps/web test
(cd apps/web && npx tsc --noEmit)
npm --prefix apps/web run check
npm --prefix apps/web run build
uv run --locked --extra dev ruff check openprogram tests scripts
uv run --locked --extra dev python -m pytest -q tests/contracts
uv run --locked --extra dev python -m pytest -q tests/unit
uv run --locked --extra dev python -m pytest -q tests/component
uv run --locked --extra dev python -m pytest -q tests/integration
uv run --locked --extra dev python -m pytest -q -m "not browser" tests/e2e
uv run --locked --extra dev --extra browser python -m pytest -q -m browser tests/e2e/web
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.build
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.check_landing
uv run --locked --with markdown-it-py --with mdit-py-plugins --with pygments python -m scripts.docs_site.checklinks
git diff --check

浏览器自动测试使用隔离状态,不发送真实用户消息或调用外部模型。产品可视验收只用 /Applications/OpenProgram.app 与默认 profile/18100/~/.openprogram。不启动 dev profile、第二套前端或作为可见 App 的测试 Electron。完整功能审查并提交后,从该 checkout 执行 scripts/refresh-local-app.sh 刷新一次,再验主栏 ack 前发送、peer 隔离、旧窗口发送、Jump 增高/取消、欢迎页和 session 恢复。保存实际几何和截图,HTML 预览不算证据。

台账目录 reports/chat-transcript-follow/:逐阶段记录 base/candidate、RED/GREEN 命令结果、F01–F18 对应测试名和证据、契约/质量结论、修复、最终完整检查输出、干净的范围内 diff 与 App 验收。源码测试、commit、已安装 App、远端状态分别记录。证据未齐不能标完成。回退 B 必须整体回退 hook+send+loader+no-op,不能单独恢复冲突的写入者。

实现状态

范围状态边界
chat-scroll.ts 数学(isChatAtBottom、松弛量、存储、animateJumpToLatest、resolveChatScrollTop)已有take-latest 记录、latestScrollTop、overlay 与 pad、可取消 Jump 都在此模块。
useChatAreaStick + Jump portal已有唯一 React 所有者。发送身份是 take-latest 记录,不是 lastRole。Peer overlay 是 composer 根。
发送时的乐观用户行已有套接字接受时 appendLocalUserTurn 为提交滚动器记下 take-latest。
遗留 scrollToBottom / 模块 stick 标志已删除Web 生产无调用方。handler 不滚动。
useStickToBottom已删除文件已删。
历史加载器 scrollTop 写入已有加载器不 pin latest。各视口恢复在 history-viewport helper,跟随锁定期跳过。
本页的统一跟随契约已有App 与浏览器共用此 Web 渲染器。证据是已审查的实现提交,不是本预览。

发布:无特性开关。没有发送身份(lastRole 回退或同一列车里的 noteTakeLatest)时不要合并 hook 改动。若部分栈之后从向上翻处发送失败,回退该列车。App 与浏览器共享 Web 渲染器;回滚是 git revert,不是协议回滚。

本次实施默认

参考