Files
2026-07-30 18:06:39 +08:00

10 KiB
Raw Permalink Blame History

调研记录:侧栏标题刷新风暴

用户现象

  • 问题偶发,子代理较多、运行中会话集中时出现。
  • 标题/会话列表区域持续刷新重绘,期间无法可靠点击。
  • 一段时间后会自行恢复。

截图观察

  • 侧栏同时展示多个“运行中”会话,项目分组数量较多。
  • DevTools 中 .session-list 包含大量 .session-project-group 节点,多数处于 collapsed 状态。
  • 问题区域是会话/项目分组列表,不是消息正文。

当前假设

  • 高频会话或子代理状态事件触发了侧栏全量渲染。
  • 全量渲染替换点击目标节点,导致 mousedown 与 click 期间节点身份变化,表现为“点不了”。
  • 需要区分事件风暴、无变化数据重复发布、前端缺少合并,以及 DOM 全量重建四类可能原因。
  • 用户明确提醒“子代理多”只是猜测;后续必须用调用频率、数据变化和节点身份等证据独立验证,不能按该猜测定向修复。

项目初始化发现

  • Trellis 开发者身份已存在:shiyue。
  • 当前 Trellis 任务指向旧的 07-17-gilded-wasteland-theme,与本次侧栏刷新问题不一致;开始实现前需确认其状态并避免把本次产物混入旧任务。
  • 项目为单仓库,规范层包含 frontend 与 backend;本问题预计优先读取前端规范。

代码索引初查

  • codebase-memory-mcp 项目 home-cc-web 已就绪:4203 个节点、9129 条边。
  • 侧栏列表核心入口命中 public/app.js 的 renderSessionList(索引行 8640–8750),入度为 8,说明存在多个调用来源。
  • 该函数可见代码会为置顶分组和普通项目分组重新 document.createElement('section') 并追加到 sessionList;是否每次调用都先清空列表、是否全部调用都需要重建,仍需读取完整源码和入站调用链确认。
  • 检索结果也命中已弃用的 graphify-out 噪声;后续不使用其产物,继续按项目约定只使用代码索引和源码校验。

已确认的渲染行为

  • renderSessionList() 第一行执行 sessionList.innerHTML = '',随后重新创建全部置顶分组、项目分组、会话项及事件监听器;因此每次调用都会替换整个侧栏列表节点树。
  • 入站调用者至少有 8 个:applySessionSnapshot、handleServerMessage、syncViewForAgent、openSession、setProjectCollapsed、applySessionPinnedState 等。
  • 这已经证实“节点会被整体替换”,但尚未证实哪个调用源形成高频触发;下一步需要检查会话快照和服务器消息处理是否在数据未变化时仍调用渲染。
  • 需要关注的交互机制是:全量替换如果发生在指针按下与抬起之间,浏览器不会把它识别为对原按钮的有效点击。这能解释现象,但仍需事件链证据完成根因闭环。

前端消息触发点

  • 收到每一条 session_list 都会无条件替换 sessions 并调用 renderSessionList(),没有内容相等或列表视图签名判断。
  • 收到每一条 session_message 后也会调用 renderSessionList();该路径会处理当前会话和跨会话消息。
  • session_renamed、置顶变化、项目折叠、搜索输入等也会调用,但这些通常是低频交互,暂时不是“持续一段时间后恢复”的首要嫌疑。
  • 流式 text_delta 与 content_blocks 只调度消息区渲染,不直接刷新侧栏,因此不能简单把所有模型流式输出都归因于侧栏重绘。
  • 下一步必须追踪服务端 session_list / session_message 的发布频率与触发条件,并检查子代理状态更新是否会间接生成这些事件。

服务端会话列表发布链路

  • sendSessionList(ws) 每次都会同步扫描会话目录、读取所有会话元数据、排序并发送完整 session_list;它本身没有去重或节流。
  • broadcastSessionList() 会遍历所有已连接客户端,并为每个客户端重新执行一次完整会话扫描和发送。
  • broadcastSessionList 有 13 个直接调用入口,除标题、置顶、跨会话消息外,明确包含 sendCcwebMcpChildAgentUpdate。
  • sendSessionList 还有 15 个直接调用入口,包括普通消息、Codex App 消息、turn 完成、进程完成等。
  • 这说明子代理更新确实可能放大会话列表推送,但它只是候选链路之一;需要继续检查 sendCcwebMcpChildAgentUpdate 的调用频率、是否每个增量都广播,以及现场运行日志/浏览器计数。

子代理更新链路证据

  • syncCcwebMcpChildAgentsFromCollabItem() 会对每个接收线程更新 child 状态,并在循环内调用 sendCcwebMcpChildAgentUpdate()。
  • sendCcwebMcpChildAgentUpdate() 先向当前会话发送局部 ccweb_mcp_child_agent_update,随后无条件调用 broadcastSessionList()。
  • 因此一次包含多个 receiver thread 的协作事件会在同一循环中多次广播完整会话列表;每次广播又会让每个浏览器客户端重新扫描服务端全部会话文件,并让前端整段替换侧栏 DOM。
  • 前端对局部 ccweb_mcp_child_agent_update 只更新工具卡和缓存,本身不刷新侧栏;造成侧栏重建的是其后附带的完整 session_list 广播。
  • 这条链路与用户描述吻合,但仍需验证现场事件量,以及非子代理来源是否也能形成同等频率的完整列表推送。

可见数据变化与历史背景

  • 每次 child 增量都会经 updatePersistedCcwebMcpChildTool() 把父会话的 session.updated 改为当前时间并保存整份会话;因此完整 session_list 的载荷确实每次不同,简单做“完整 JSON 相等去重”无法挡住这类刷新。
  • 会话项真正渲染的字段包括标题、运行/未读/等待状态、置顶、项目信息以及相对更新时间;更新时间只是其中唯一在每个 child 增量必变的侧栏字段。
  • waitingOnChildren 来自跨会话回复队列,不来自 MCP child-agent 状态;因此仅为了 child 工具卡进度而广播完整列表,并不是更新该等待徽标所必需。
  • Git 追溯显示这条“child 更新后无条件广播列表”的实现自 2026-06-16 的 Codex App 集成改动起存在,并非本轮临时改动。
  • 现有回归覆盖 child 工具卡路由与 V2 subAgentActivity,尚未覆盖侧栏节点稳定性或重复完整列表刷新。

修复方向候选

  • 服务端只移除 child 更新后的列表广播:成本低,但无法防住普通 session_message、其他重复 session_list 等来源。
  • 前端只做延迟/节流:能降频,但连续事件期间仍会周期性替换点击目标,不能从机制上保证可点击。
  • 前端基于“影响布局与交互的视图签名”跳过结构相同的全量重建,并仅原位更新时间文本:可保留节点身份;当会话顺序、标题、状态、分组、搜索或折叠状态变化时仍正常重建。当前优先推荐此方案,并考虑同时删除明显冗余的 child 列表广播。

计划审查结论

  • 独立计划审查已通过,无阻塞问题。
  • 回归测试需明确验证高频状态更新期间项目分组节点身份稳定、点击能力不丢失,不能只比较最终 DOM 文本。

待补充

  • 当前环境没有 Chromium、Playwright 或 Puppeteer,真实浏览器交互降级为最小 DOM 行为回归;该回归已验证清空次数从纯更新时间每次 +1 降为保持不变,并验证项目组/会话项对象身份与点击监听器不变。
  • 部署后再观察运行态 CPU 与 HTTP 延迟;修改尚未重启前不能把旧进程指标误当作新实现指标。

规范与任务上下文

  • 已建立独立 Trellis 任务 07-30-sidebar-title-refresh-storm,未结束或归档原有旧主题任务。
  • 前端规范文件目前多数仍是占位模板;实现需主要遵循仓库现有 vanilla JS 模式、回归脚本模式和本任务 PRD。
  • PRD 明确不移除协议广播,而在前端渲染边界区分结构变化与纯时间变化,以覆盖子代理和普通消息等同类来源。

最终实现

  • buildSessionListStructureSignature() 对已完成排序、分组、折叠/旧会话拆分后的渲染模型生成签名。
  • 签名排除原始 updated,但纳入排序后的可见/隐藏会话顺序、标题、项目、置顶、运行、未读、等待、当前会话、搜索与折叠状态;因此纯时间变化不重建,时间造成排序/可见性变化时仍会重建。
  • refreshSessionListRelativeTimes() 在签名相同时按 data-id 原位更新 .session-item-time,不会替换项目分组、会话项或监听器。
  • 回归使用最小 DOM stub 实际执行两次渲染,验证清空次数、节点对象身份、点击监听器、时间文本,以及标题/状态/顺序变化后的重建。
  • 初版回归的 splitCollapsedSessions() stub 永远不返回隐藏会话,漏掉了 createOldSessionLoadMoreButton() 分支,导致 oldSessionCollapseKey 漏解构未被发现;现已加入真实隐藏会话场景并先复现同一错误。
  • public/index.html 的 app/style 缓存版本已更新为 20260730-sidebar-title-refresh-storm,避免浏览器继续复用含该错误的旧脚本。
  • updatePersistedCcwebMcpChildTool() 对同一父会话的 running 增量做 250ms 尾随合并;冲刷前重新加载最新父会话并重放每个 child 的最新状态,避免覆盖父 turn 同期写入。
  • scheduleCcwebMcpChildSessionListBroadcast() 合并 child 引起的全量列表广播;returned、failed、interrupted、closed 会立即保存并广播。
  • 局部 ccweb_mcp_child_agent_update 仍对每个增量立即发送,工具卡实时性不受批处理影响。

最终审查与运行态

  • 独立最终审查通过,无新的阻塞问题。
  • 结构签名现在只在空态或完整 DOM 成功渲染后提交;部分渲染异常后的下一次快照会重新构建,而不是接受半成品 DOM。
  • pending child 状态按 threadId || spawnToolId 分别保存,同一可见工具下的 sibling/nested child 不再互相覆盖。
  • 当前进程未再次重启:仓库要求重启前先确认没有其他运行中的对话,但本轮重启后该会话列表工具未重新注入,因此保守保留现有在线进程。
  • 前端静态资源已通过新缓存版本在线提供;服务进程当前已加载此前重启前的批处理实现,最新 sibling key 变更待下一次安全重启后加载。