采用
一份策略模块,位于 apps/web/lib/chat/chat-scroll.ts。每个滚动器一个 attached 布尔。发送 / 队列 drain / 欢迎页第一条 = take-latest,瞬时贴底。流式只在已附着且位于最新历史窗口时 pin。跳到最新仍是显式平滑重新附着。首次激活定位最新,随后激活恢复已存位置,不创建聊天消息。桌面 App 使用同一套 web 渲染器。
OpenProgram / Web / 转录
发送消息并不能可靠地把读者带到最新一轮。四个重叠的滚动写入者对“底部”是什么、谁可以移动视口意见不一(遗留 scrollToBottom、未使用的 useStickToBottom、现场 useChatAreaStick、以及 loadSessionHistoryWindow)。本页把它们收成一份由 React 持有的跟随策略:一种附着/脱离状态、一个“在底部”判定、一张跟随触发表、以及一个以 padding 边缘定义的 latest。聊天 handler 和历史加载器不得把 latest 钉到任意视口上。
该视口完全本地,不发网络请求。它实现的是统一策略,不是当前产品行为。可以灌入长历史、向上翻、发送、模拟流式,并使用“跳到最新”。在最新窗口上,发送瞬间贴底并附着。正在显示更早窗口时,发送保持 pending、保留 Jump,latest 加载后再贴底。流式增长只在附着时跟随。“跳到最新”用原生平滑滚动重新附着(请求减少动效时改为瞬间)。
键盘: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 没有响应自动首次激活的最新请求,却等待非零初始位置。四个 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 修好,予以保留。
latest pin 到 registered.keys().next() 的 scrollHeight 上。数据发布后,匹配 hook 执行发送 snap 或 Jump smooth;窗口替换按各视口恢复,见 History 恢复的执行归属。useChatAreaStick 是 DOM 施加器与状态机(stuckRef、jumpingRef、lastPointerRef、pending latest 加载)。MessageList 与 peer pane 传入滚动器、列、overlay 读取器和滚动器 chatKey。chat-scroll.ts(latestScrollTop、take-latest 记录、overlay vs pad、snapToLatest、resolveChatScrollTop 作为是否移动)。hook 施加它们。没有 decideFollow 动作枚举。stuckRef 并 snap。当 after 置位时,take-latest 为 pending:保持脱离,保留 Jump,不对过期页 snap。600ms 仍挡住无关的转录区内增长。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 条) | jump | latest 上屏后为 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 / 回收 / 垫高 / 输入框 resize | growth | 不变 | 仅在已附着时 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。 |
sendChatMessage(或斜杠提交)在适用时调用 appendLocalUserTurn,然后 noteTakeLatest({ sessionId, scrollerKey, turnSeed })。sendChatMessage 内部默认:scrollerKey = background ? "peer:" + sessionId : sessionId。交互提交可以显式传入 scrollerKey。排出时不要读取现场 activeChatKey(那是聚焦会话,未必是正在排出的会话)。队列排出因已带 sessionId 和 background 调用 sendChatMessage 而继承;send-queue.ts 保持不变。generation 新于 lastAppliedGeneration 且 scrollerKey 等于本实例 chatKey 的记录。其他 pane 不闩。记录不在读取时删除(Strict Mode 与两个 hook 实例)。history.after 置位:不设 attached,不 snap,保留 Jump。启动与 Jump 相同的异步 isCurrent await。layout effect 不得 await。isChatAtBottom,设 stuckRef = true,立刻 snapToLatest。ResizeObserver 回调也 snap(迟到的行高)。该 pin 因 take-latest generation 仍当前而忽略 600ms。无关的转录区内增长仍遵守 600ms。welcomeVisible 或观察 #welcome-mount 子节点。overflow 锁解开时,若同一 take-latest generation 仍当前则重试 snap 一次。Peer 发送使用 background: true,跳过欢迎页。lastRole 推导。note 标识一次发送,不代表永久滚动许可。按 (sessionId, scrollerKey) 独立保存,使用 peekTakeLatest(sessionId, scrollerKey) 读取;同 session 的主栏与 peer 发送不能互相覆盖。发布 note 必须通知匹配的 view,包括没有新增转录行的 slash submit;不能只在无关 render 中读取可变 Map。turnSeed 标识已接受的乐观用户行,不要求等于 ack 之后的最后一行。
local_* 切到任意非草稿」。ack 替换 message id 后,不要求 turnSeed 继续等于最后一行 id。history.after || history.loading 进入异步协调,然后验证返回窗口和操作身份。取消/失败不遗留强制重试、不隐藏 Jump;后续新手势仍可成功,旧完成回调不得把新操作设为 detached 或清除它。一次 next-resize 重试属于该操作,不属于整个助手回合。用完后恢复 attached 与正常手势规则;generation 未变不代表无限期绕过 600ms。每个 hook 管理自己的资源和清理,不增加轮询计时器或持久化 attached。
latest 加载成功后,Jump 直接从保留位置开始平滑滚动,禁止先 snap。Jump 期间 resize 记录目标变化,不瞬间 pin。原生滚动停止时重新计算 latestScrollTop;目标变动则结束原动画,瞬间补齐到当前 padding 边,并验证位置。只有位置验证通过且操作仍有效,才隐藏 Jump。reduced-motion 从开始就瞬间定位,使用同样的最终验证。
超时仅用于结束异常等待,不证明已经到达。超时后,仍有效的操作先停止原生运动,再补齐到当前目标、验证并完成。取消既要停止回调,也要停止浏览器动画;只删除 scrollend listener 不算取消。已取消操作不得补齐。后续增长恢复普通 attached pin。定位允许 2px 几何误差,不用更大的再附着 slack 代替 Jump 到达验证。
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 隔离不承诺独立历史数据集。
sessionStorage 键 chatScrollByKey 仍只存像素位置。不要持久化 attached。加固上下文里写失败已经吞掉。data-self-update-verification 继续跳过持久化。!isCurrent:保持脱离,Jump 仍可见,不对过期页 snap,也不让 pending take-latest 把 attached 留在 true。Jump 在 loadSessionHistoryWindow 为 false 时已经返回。scrollHeight 诚实。回收后 pin。不要加 react-virtuoso / react-window / @tanstack/react-virtual。typesetMath,作为单一 pin 路径里的增高副作用,不是第二套滚动器,也不扫描整页。ScrollBox 不在范围内。easeScrollBy / pinWhile、父返回 scrollIntoView、消息轨定位、或分支检出枢轴(_postCheckoutScrollTo)。那些仍是显式定位。appendLocalUserTurn 调用处(以及斜杠提交)记一条 take-latest,不改写 occupancy、发送队列准入或 ack 配对。#chatView portal、live 条或 composer 偏移定位。扩展 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。
| 阶段 | 主要文件(仓库相对路径) | 交付要求 |
|---|---|---|
| A | apps/web/lib/chat/chat-scroll.tsapps/web/tests/chat/chat-scroll-follow.test.mjsapps/web/tests/chat/jump-latest.test.mjs | 原语、按键可订阅 note、精确定位、可取消的 Jump helper。B 之前保留现有调用兼容。 |
| B | apps/web/components/chat/messages/use-chat-area-stick.tsapps/web/components/chat/messages/message-list.tsxapps/web/components/chat/peer-session-pane.tsxapps/web/components/chat/composer/submit/send-chat-message.tsapps/web/components/chat/composer/submit/use-chat-submit.tsapps/web/lib/runtime-bridge/session-history-loader.tsapps/web/lib/chat/history-viewport.tsapps/web/components/chat/messages/use-history-window.tsapps/web/lib/runtime-bridge/helpers.ts | 完整接入:note 发布/订阅、生命周期、latest 加载、各视口恢复、overlay reader、welcome 交接、Jump 完成、legacy no-op 并移除全局 listener。同一候选中删除 lastRole 推断。 |
| C | apps/web/lib/runtime-bridge/helpers.tsapps/web/lib/runtime-bridge/chat-handlers.tsapps/web/lib/runtime-bridge/conversations.tsapps/web/lib/format-utils/scroll.tsapps/web/lib/runtime-bridge/state.ts | 删除 no-op 调用/import、死代码 useStickToBottom、废弃 _skipScrollToBottom 声明与赋值。先查全引用,保留显式定位 helper。 |
| D | Both chat-transcript-follow HTML pagesapps/web/tests/chat/send-chat-message-latency.test.mjsapps/web/tests/desktop/self-update-scroll.test.mjsapps/web/scripts/chat/check-chat-ui.mjstests/e2e/web/test_chat_history_interactions.pytests/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 | 阶段 | 必须验证的结果 |
|---|---|---|
| F01 | A | 空/短/溢出几何;主栏 slack 与 peer h+24 对 h 得到 32px。 |
| F02 | A/B | 同 session 主栏/peer note 独立;generation 递增;不更新行也触发订阅。 |
| F03 | B | 上翻后分别 Enter/Send:socket 接受后、ack 前用户行在 overlay 上方;placeholder 与流式继续跟随。 |
| F04 | B | 滚动后 600ms 内发送,迟到列增高补齐;发送后在迟到增高前上翻,不再强制贴底。 |
| F05 | B | 脱离后流式/工具/数学增高保持位置;滚回 slack 再附着;执行行展开保留抑制。 |
| F06 | B | after 页发送不写旧页,pending 保留 Jump;成功贴底,失败/取消保持脱离。 |
| F07 | B | 当前无 after 但正在加载 older/around 时发送;协调旧请求后加载 latest;旧响应不能覆盖新手势。 |
| F08 | B | StrictMode 首发及 pending latest cleanup/setup:不丢意图、不重复逻辑发送、不遗留 pending。 |
| F09 | B | 发送、脱离、切出切回;真正卸载/重挂恢复已存位置,不重放。请求期间切换不移动新 chat。 |
| F10 | B | transcript 未就绪时欢迎页首发与 provisional ack 绑定;有效重试一次,取消后不写。 |
| F11 | B | peer Y 发送、pane X 脱离:只移动 Y。同 session 主栏/peer note 独立;共用 peer key 的 pane 明确共同响应。 |
| F12 | B | 仅入队不移动;焦点变化后真正 drain 仍用保存的 session/background。socket 关闭/抛错或重复首发不产生新 note。 |
| F13 | B | slash/builtin 提交不依赖立即新增行也跟随;脱离时远端 local_command/compaction/status 不跟随。 |
| F14 | B | 旧窗口 Jump 加载后实际平滑滚动,不先 snap;验证完成前按钮保留。 |
| F15 | B | Jump 期间增高、动画结束前停止增高:最终距当前 latest 不超过 2px 再藏按钮;另测无 scrollend 的超时分支。 |
| F16 | B | 指针/滚轮/滚动键取消 pending 或原生 Jump,无后续动画/补齐;Jump 中发送瞬间生效;减少动效时瞬间定位。 |
| F17 | B | 两个位置不同的注册区域分别恢复 older/newer/around 锚点及回退高度差;latest 无加载器任意 pin。 |
| F18 | C/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,不是协议回滚。
appendLocalUserTurn 已过时)