diff --git a/.planning/sidebar-title-refresh-storm/findings.md b/.planning/sidebar-title-refresh-storm/findings.md new file mode 100644 index 0000000..06e73be --- /dev/null +++ b/.planning/sidebar-title-refresh-storm/findings.md @@ -0,0 +1,114 @@ +# 调研记录:侧栏标题刷新风暴 + +## 用户现象 + +- 问题偶发,子代理较多、运行中会话集中时出现。 +- 标题/会话列表区域持续刷新重绘,期间无法可靠点击。 +- 一段时间后会自行恢复。 + +## 截图观察 + +- 侧栏同时展示多个“运行中”会话,项目分组数量较多。 +- 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 变更待下一次安全重启后加载。 diff --git a/.planning/sidebar-title-refresh-storm/progress.md b/.planning/sidebar-title-refresh-storm/progress.md new file mode 100644 index 0000000..e351232 --- /dev/null +++ b/.planning/sidebar-title-refresh-storm/progress.md @@ -0,0 +1,90 @@ +# 进度记录:侧栏标题刷新风暴 + +## 会话:2026-07-30 + +### 阶段 1:确认索引与侧栏更新链路 + +- **状态:** complete +- **开始时间:** 2026-07-30 15:25 +08:00 +- 已完成: + - 阅读用户截图并记录视觉现象。 + - 扫描并启用 `planning-with-files` 与 `todo-list-csv` 技能。 + - 执行会话恢复检查,未发现未同步上下文。 + - 完成 Trellis 会话初始化检查,发现当前任务仍指向一项旧主题任务。 + - 将用户关于“不要按猜测调查”的约束写入调研记录。 + - 计划文档通过独立子代理审查。 + - 确认 `home-cc-web` 代码索引就绪,初步定位 `renderSessionList`。 + - 读取 `renderSessionList` 完整源码并追踪入站调用,确认每次调用都会清空并重建整个列表。 + - 核验 `handleServerMessage`:`session_list` 与 `session_message` 均会无条件全量重建侧栏,流式文本事件不会直接重建。 + - 追踪服务端列表发布链路:完整列表无去重/节流,且子代理状态更新是广播入口之一。 + - 建立子代理更新闭环:每个 child 增量都会无条件广播完整 `session_list`,前端随后整段替换侧栏。 + - 确认 child 增量每次都会改写父会话 `updated`,所以完整载荷去重不足;现有测试也未覆盖节点身份稳定性。 + +### 阶段 2:复现并定位高频重绘根因 + +- **状态:** complete +- **开始时间:** 2026-07-30T07:35:21.137Z +- 下一步: + - 已通过代码调用链确认子代理与普通消息均可能触发全量替换。 + - 已确认服务运行指标正常,排除服务进程阻塞。 + +### 阶段 3:回归测试与修复 + +- **状态:** complete +- **开始时间:** 2026-07-30T07:43:47.002Z +- 已完成: + - 创建独立 Trellis 任务、PRD、根因研究文件及 implement/check 上下文。 + - 新增 DOM 级失败回归,旧实现按预期失败于“仅 updated 变化仍清空列表”。 + - 新增结构签名缓存和相对时间原位刷新,实现节点身份与点击监听器稳定。 + - 新增服务端失败回归,旧实现缺少 child 更新合并并会逐次保存、逐次广播完整列表。 + - 同一父会话 250ms 内复用待保存快照,尾随冲刷前重读最新会话;最终态立即保存。 + - 合并 child 引起的完整列表广播,同时保留每次局部工具卡 WebSocket 更新。 + +### 阶段 4:验证与交付 + +- **状态:** complete +- **开始时间:** 2026-07-30T08:10:22.442Z +- 已完成: + - 定向侧栏刷新回归通过。 + - 侧栏折叠、会话工具提示、子代理卡片路由回归通过。 + - `public/app.js` 与 `scripts/regression.js` 语法检查通过。 + - `git diff --check` 通过。 + - 完整 `npm run regression` 在 60 秒超时约束内通过。 + - 服务端 child 更新合并定向回归通过,既有子代理卡片路由回归通过。 + - 用户重启后暴露 `oldSessionCollapseKey` 漏解构;新增历史会话折叠分支回归先复现同一 ReferenceError,再补回字段。 + - 更新前端静态资源缓存版本;从运行中的 HTTP 服务确认新 index 与修复后的 app.js 已生效。 + - 修正后定向回归、语法检查、diff 检查与 60 秒完整回归再次通过。 + - 最终独立 diff 审查通过,确认签名提交时机、sibling child 保留、缓存版本号和未定义变量均无阻塞问题。 + - 重启后的运行态观察:ccweb 同一 PID 在线,CPU 约 0.6%,连续 HTTP 200 约 0.9–5.8ms;未读取敏感环境配置。 +- 下一步: + - 无;服务端后续重启需先确认除当前对话外没有其他 running 对话。 +- 创建文件: + - `.planning/sidebar-title-refresh-storm/task_plan.md` + - `.planning/sidebar-title-refresh-storm/findings.md` + - `.planning/sidebar-title-refresh-storm/progress.md` + +## 测试结果 + +| 测试 | 预期 | 实际 | 状态 | +|---|---|---|---| +| 失败回归(修复前) | 仅 updated 变化不应再次清空 | 缺少结构签名/旧实现再次清空 | 预期失败 | +| 服务端失败回归(修复前) | child 突发合并保存与完整列表广播 | 缺少 `isFinalCcwebMcpChildStatus` 及批处理实现 | 预期失败 | +| 侧栏刷新定向回归 | 节点稳定且真实结构变化重建 | 通过 | ✓ | +| child 更新合并回归 | 3 次局部推送、1 次保存、1 次完整列表广播,最终态立即落盘 | 通过 | ✓ | +| 历史会话折叠失败回归 | 隐藏会话存在时应保留折叠键并正常渲染 | 修复前复现同一 ReferenceError;修复后通过 | ✓ | +| 部分渲染失败恢复回归 | 首轮异常后再次渲染必须重建完整置顶/项目 DOM | 修复前失败;签名延后提交后通过 | ✓ | +| 共享 spawnToolId sibling 回归 | 同一工具下多个 child 最终状态都落盘 | 修复前第一个 sibling 丢失;按 threadId 去重后通过 | ✓ | +| 相关定向回归 | 折叠、提示、子代理路由无回退 | 通过 | ✓ | +| JS 语法与 diff 检查 | `server.js`、`public/app.js`、`scripts/regression.js` 无语法/空白错误 | 通过 | ✓ | +| 完整 regression | 全量回归通过 | Regression checks passed | ✓ | + +## 错误日志 + +| 时间 | 错误 | 次数 | 处理 | +|---|---|---:|---| +| 2026-07-30T07:49:41.008Z | 误用执行单元等待接口 | 6 | 切换到 `collaboration.wait_agent`;实现任务未受影响 | +| 2026-07-30T07:49:41.008Z | 错误日志补丁再次因动态时间上下文不匹配 | 1 | 改为匹配稳定表头追加 | +| 2026-07-30T07:44:54.498Z | full-history fork 不能同时覆盖为 worker 角色 | 1 | 改用 `fork_turns: none`,通过磁盘 PRD 和 JSONL 注入完整上下文 | +| 2026-07-30T07:46:30.649Z | Default 模式下 `request_user_input` 不可用 | 1 | 不需要用户输入,停止调用并继续实现 | +| 2026-07-30T07:46:30.649Z | 错误日志补丁的动态时间上下文不匹配 | 1 | 读取稳定尾部后重新追加,未重复原失败补丁 | +| 2026-07-30T16:45:00+08:00 | `renderSessionList` 漏解构 `oldSessionCollapseKey`,用户重启后前台 ReferenceError | 1 | 新增实际隐藏会话分支回归复现,补回解构并更新缓存版本,完整回归通过 | diff --git a/.planning/sidebar-title-refresh-storm/task_plan.md b/.planning/sidebar-title-refresh-storm/task_plan.md new file mode 100644 index 0000000..19fbd86 --- /dev/null +++ b/.planning/sidebar-title-refresh-storm/task_plan.md @@ -0,0 +1,56 @@ +# 任务计划:修复侧栏标题刷新风暴 + +## 目标 + +定位并修复子代理活跃时侧栏会话标题区域频繁刷新重绘、暂时无法点击的问题,同时建立可重复的回归验证。 + +## 当前阶段 + +阶段 4:验证与交付 + +## 阶段 + +### 阶段 1:确认索引与侧栏更新链路 +- [x] 确认代码索引状态 +- [x] 定位侧栏列表渲染、会话状态事件和子代理更新入口 +- **状态:** complete + +### 阶段 2:复现并定位高频重绘根因 +- [x] 建立事件到 DOM 更新的调用链 +- [x] 确认是否存在全量重建、重复事件或无效状态更新 +- **状态:** complete + +### 阶段 3:回归测试与修复 +- [x] 先编写能覆盖状态更新风暴的回归测试 +- [x] 实施最小且可维护的修复 +- **状态:** complete + +### 阶段 4:验证与交付 +- [ ] 运行相关单元测试、静态检查与构建 +- [ ] 进行浏览器交互和性能验证 +- [ ] 审查变更并整理结论 +- **状态:** in_progress + +## 关键问题 + +1. 哪类子代理/会话事件会触发侧栏刷新,频率和生命周期如何? +2. 侧栏是否使用 `innerHTML` 或等价方式重建全部项目分组? +3. 是否能在不牺牲实时状态的前提下保持节点身份和点击稳定性? + +## 已作决策 + +| 决策 | 理由 | +|---|---| +| 优先验证状态更新风暴与全量 DOM 重建的组合问题 | 用户描述与截图均指向运行会话密集时的短时高频重绘 | +| 先补回归测试再改实现 | 该问题偶发,必须把触发条件固化成可重复检查 | +| 使用结构视图签名并原位更新时间 | 保留节点身份,同时不牺牲真正的顺序、标题和状态更新 | + +## 遇到的错误 + +| 错误 | 次数 | 处理 | +|---|---:|---| +| 多次误把代理等待当成执行单元等待 | 20+ | 后续停止通用轮询,改用代理主动结果、共享工作区变更与正确协作接口 | +| 错误日志再次使用动态时间作补丁上下文 | 1 | 改为只匹配稳定表头追加记录 | +| 使用 full-history fork 同时指定 worker 角色被运行时拒绝 | 1 | 改为无历史 fork,并在任务提示中要求读取 Trellis PRD、上下文和项目规范 | +| Default 模式误调用引导输入工具 | 1 | 本任务不需要用户选择,停止调用并继续既定实现流程 | +| 记录错误时使用了变化后的动态时间导致补丁上下文不匹配 | 1 | 先读取文件尾部,再基于稳定行追加记录 | diff --git a/.trellis/tasks/07-30-sidebar-title-refresh-storm/check.jsonl b/.trellis/tasks/07-30-sidebar-title-refresh-storm/check.jsonl new file mode 100644 index 0000000..a60eb87 --- /dev/null +++ b/.trellis/tasks/07-30-sidebar-title-refresh-storm/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "检查前端代码质量与回归覆盖"} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "检查视图签名与状态同步正确性"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查消息协议到 DOM 行为没有语义回退"} +{"file": ".trellis/tasks/07-30-sidebar-title-refresh-storm/research/root-cause.md", "reason": "按已确认根因和验收重点审查实现"} diff --git a/.trellis/tasks/07-30-sidebar-title-refresh-storm/implement.jsonl b/.trellis/tasks/07-30-sidebar-title-refresh-storm/implement.jsonl new file mode 100644 index 0000000..cfbc3d6 --- /dev/null +++ b/.trellis/tasks/07-30-sidebar-title-refresh-storm/implement.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "遵循前端质量与测试约束"} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "侧栏由客户端状态与服务端快照共同驱动"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "复用现有分组、排序与 timeAgo 逻辑"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "核验服务端快照到前端 DOM 的完整数据流"} +{"file": ".trellis/tasks/07-30-sidebar-title-refresh-storm/research/root-cause.md", "reason": "实现所需的根因证据、边界与测试重点"} diff --git a/.trellis/tasks/07-30-sidebar-title-refresh-storm/prd.md b/.trellis/tasks/07-30-sidebar-title-refresh-storm/prd.md new file mode 100644 index 0000000..308f82c --- /dev/null +++ b/.trellis/tasks/07-30-sidebar-title-refresh-storm/prd.md @@ -0,0 +1,48 @@ +# 修复侧栏标题刷新风暴 + +## 目标 + +修复会话或协作状态高频更新期间侧栏会话列表反复整段重建、视觉闪烁且点击目标失效的问题,同时保持标题、排序、运行状态、未读状态、等待状态、搜索与折叠行为实时正确。 + +## 已确认根因 + +1. 服务端的多类会话事件会发送完整 `session_list`;MCP child-agent 每次增量还会更新父会话的 `updated` 并广播完整列表。 +2. 前端收到 `session_list` 或 `session_message` 后调用 `renderSessionList()`。 +3. `renderSessionList()` 无条件执行 `sessionList.innerHTML = ''`,重建所有项目分组、会话项和监听器。 +4. 当重建发生在指针按下与抬起之间时,原点击节点已被替换,浏览器不会产生有效点击。 + +## 需求 + +- 在侧栏渲染边界计算“结构视图签名”,只有影响列表结构、排序或交互状态的内容变化时才重建 DOM。 +- 单纯 `updated` 时间变化且会话顺序未变化时,不得替换现有项目分组或会话项节点。 +- 跳过结构重建时,原位刷新现有会话项的相对时间文本。 +- 会话顺序、标题、置顶、运行、未读、等待/回复计数、项目归属、当前会话、当前代理、搜索条件、项目折叠或旧会话展开状态变化时,仍必须重建。 +- 不改变 WebSocket 消息协议,不删除必要的未读/排序状态广播。 +- 同一父会话的连续 child-agent 增量应合并整份会话的加载、序列化与落盘;冲刷前必须基于最新父会话合并,不能覆盖同期父 turn 写入。 +- child-agent 引起的完整 `session_list` 广播应短周期合并,但局部 `ccweb_mcp_child_agent_update` 必须逐次实时发送。 +- `returned`、`failed`、`interrupted`、`closed` 最终态必须立即可靠冲刷,不能被尾随定时器覆盖或丢失。 +- 不引入新的前端依赖。 + +## 验收标准 + +- [ ] 首次渲染正常建立侧栏 DOM。 +- [ ] 同一列表连续收到仅 `updated` 不同的快照时,`.session-project-group` 与 `.session-item` 节点身份保持不变。 +- [ ] 上述快速更新期间,现有点击监听器和点击目标保持有效。 +- [ ] 跳过结构重建时,`.session-item-time` 文本能原位更新。 +- [ ] 会话顺序、标题、状态、分组、搜索或折叠状态变化会触发一次正确重建。 +- [ ] 多次 running child 增量只触发一次父会话保存和一次完整列表广播,同时局部 child 更新逐次发送。 +- [ ] child 最终态立即落盘并取消残留尾随任务;最终工具结果刷新后可恢复。 +- [ ] 新增回归测试先失败、修复后通过;现有相关回归通过。 + +## 实现约束 + +- 优先在 `public/app.js` 内复用现有状态和 `timeAgo()`,避免复制分组/排序逻辑。 +- 测试沿用 `scripts/regression.js` 的函数提取与最小 DOM stub 方式。 +- 关键注释使用简体中文,说明为什么 `updated` 不应触发结构重建。 +- 保留现有搜索、折叠、置顶和会话操作事件语义。 + +## 非目标 + +- 本次不重构整个侧栏为框架组件。 +- 本次不修改 child-agent 工具卡协议。 +- 本次不通过粗暴长延迟隐藏问题。 diff --git a/.trellis/tasks/07-30-sidebar-title-refresh-storm/research/root-cause.md b/.trellis/tasks/07-30-sidebar-title-refresh-storm/research/root-cause.md new file mode 100644 index 0000000..04e3e80 --- /dev/null +++ b/.trellis/tasks/07-30-sidebar-title-refresh-storm/research/root-cause.md @@ -0,0 +1,33 @@ +# 侧栏刷新风暴根因记录 + +## 数据流 + +```text +协作/会话事件 + → 服务端保存父会话并更新 updated + → sendSessionList / broadcastSessionList + → 前端 handleServerMessage + → renderSessionList + → 清空并重建整个 session-list +``` + +## 关键证据 + +- `renderSessionList()` 第一行清空 `sessionList.innerHTML`,每次重新创建所有项目分组和会话节点。 +- `session_list` 与 `session_message` 路径都无条件调用该函数。 +- `sendCcwebMcpChildAgentUpdate()` 每次 child 增量都会调用 `broadcastSessionList()`。 +- `updatePersistedCcwebMcpChildTool()` 每次增量都会改写父会话的 `updated`,因此完整 payload 字节级比较无法去重。 +- `waitingOnChildren` 来自跨会话回复队列,不直接来自 MCP child-agent 状态。 +- 服务端事件循环指标正常,现象不是服务进程阻塞。 + +## 技术判断 + +仅移除 child 广播会遗漏普通消息等同类来源,也可能延迟未读状态。仅做节流仍会周期性替换点击节点。最稳妥的边界是前端结构视图签名:把会话数组当前顺序和所有结构/状态字段纳入签名,但排除仅用于相对时间展示的 `updated`;签名相同时原位更新时间,签名变化时才重建。 + +## 测试重点 + +- 首次渲染会清空并建立列表。 +- 仅更新时间变化时不再次清空列表,并保留原节点身份。 +- 标题、状态或顺序变化时签名变化并允许重建。 +- 原位时间刷新使用 `data-id` 精确匹配会话。 + diff --git a/.trellis/tasks/07-30-sidebar-title-refresh-storm/task.json b/.trellis/tasks/07-30-sidebar-title-refresh-storm/task.json new file mode 100644 index 0000000..28edc4b --- /dev/null +++ b/.trellis/tasks/07-30-sidebar-title-refresh-storm/task.json @@ -0,0 +1,26 @@ +{ + "id": "sidebar-title-refresh-storm", + "name": "sidebar-title-refresh-storm", + "title": "修复侧栏标题刷新风暴", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-07-30", + "completedAt": "2026-07-30T17:20:05+08:00", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index cd047f3..02bfadd 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/app.js b/public/app.js index 85b65c4..0c49875 100644 --- a/public/app.js +++ b/public/app.js @@ -246,6 +246,7 @@ let isReloadingMcp = false; const mcpStartupToastKeys = new Map(); let sessionSearchQuery = ''; + let lastSessionListStructureSignature = ''; const collapsedProjectKeys = (() => { try { const parsed = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY) || '[]'); @@ -8637,8 +8638,58 @@ updateScrollbar(); + function buildSessionListStructureSignature(model) { + const sessionSignature = (session) => ({ + id: String(session?.id || ''), + agent: String(session?.agent || ''), + title: String(session?.title || ''), + projectName: getSessionProjectName(session), + cwd: getSessionEffectiveCwd(session), + active: session?.id === currentSessionId, + pinnedAt: session?.pinnedAt || '', + createdFromKind: String(session?.createdFromKind || '').toLowerCase(), + isRunning: !!session?.isRunning, + hasUnread: !!session?.hasUnread, + waitingOnChildren: !!session?.waitingOnChildren, + readyReplyCount: Number(session?.readyReplyCount || 0), + pendingReplyCount: Number(session?.pendingReplyCount || 0), + }); + return JSON.stringify({ + agent: currentAgent, + currentSessionId, + search: model.normalizedSearchQuery, + allEmpty: model.allVisibleSessions.length === 0, + visibleEmpty: model.visibleSessions.length === 0, + pinned: model.pinnedSessions.map(sessionSignature), + groups: model.projectGroups.map((entry) => ({ + key: entry.groupKey, + oldSessionCollapseKey: entry.oldSessionCollapseKey, + name: entry.group.name, + cwd: entry.group.cwd || '', + count: entry.group.sessions.length, + collapsed: entry.isCollapsed, + hiddenCount: entry.hiddenGroupSessions.length, + visible: entry.visibleGroupSessions.map(sessionSignature), + hidden: entry.hiddenGroupSessions.map(sessionSignature), + })), + ungroupedCollapseKey: model.ungroupedCollapseKey, + hiddenUngroupedCount: model.hiddenUngroupedSessions.length, + ungrouped: model.visibleUngroupedSessions.map(sessionSignature), + hiddenUngrouped: model.hiddenUngroupedSessions.map(sessionSignature), + }); + } + + function refreshSessionListRelativeTimes(visibleSessions) { + const sessionsById = new Map(visibleSessions.map((session) => [String(session.id || ''), session])); + sessionList.querySelectorAll('.session-item').forEach((item) => { + const session = sessionsById.get(String(item.dataset.id || '')); + if (!session) return; + const timeEl = item.querySelector('.session-item-time'); + if (timeEl) timeEl.textContent = timeAgo(session.updated); + }); + } + function renderSessionList() { - sessionList.innerHTML = ''; syncSessionSearchUi(); const allVisibleSessions = getVisibleSessions(); const normalizedSearchQuery = normalizeSessionSearchQuery(sessionSearchQuery); @@ -8646,11 +8697,56 @@ const visibleSessions = isSearchingSessions ? allVisibleSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery)) : allVisibleSessions; + const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions); + const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions); + const renderGroups = projectGroups.map((group, groupIndex) => { + const groupKey = getProjectCollapseKey(group); + const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group); + const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions + ? { visibleSessions: group.sessions, hiddenSessions: [] } + : splitCollapsedSessions(group.sessions, oldSessionCollapseKey); + const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey); + return { + group, + groupIndex, + groupKey, + oldSessionCollapseKey, + visibleGroupSessions, + hiddenGroupSessions, + isCollapsed, + hasActiveSession: group.sessions.some((session) => session.id === currentSessionId), + hasUnreadSession: group.sessions.some((session) => session.hasUnread), + hasRunningSession: group.sessions.some((session) => session.isRunning), + hasWaitingSession: group.sessions.some((session) => session.waitingOnChildren), + groupBodyId: `session-project-body-${groupIndex}`, + }; + }); + const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey(); + const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions + ? { visibleSessions: ungroupedSessions, hiddenSessions: [] } + : splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey); + const structureSignature = buildSessionListStructureSignature({ + normalizedSearchQuery, + allVisibleSessions, + visibleSessions, + pinnedSessions, + projectGroups: renderGroups, + ungroupedCollapseKey, + visibleUngroupedSessions, + hiddenUngroupedSessions, + }); + if (structureSignature === lastSessionListStructureSignature && sessionList.childElementCount > 0) { + // 高频快照常只更新 updated;它只影响相对时间,不能触发节点替换。 + refreshSessionListRelativeTimes(visibleSessions); + return; + } + sessionList.innerHTML = ''; if (allVisibleSessions.length === 0) { const empty = document.createElement('div'); empty.className = 'session-list-empty'; empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`; sessionList.appendChild(empty); + lastSessionListStructureSignature = structureSignature; return; } if (visibleSessions.length === 0) { @@ -8658,11 +8754,10 @@ empty.className = 'session-list-empty'; empty.textContent = '没有匹配的会话或项目。'; sessionList.appendChild(empty); + lastSessionListStructureSignature = structureSignature; return; } - const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions); - const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions); if (pinnedSessions.length > 0) { const pinnedGroupEl = document.createElement('section'); pinnedGroupEl.className = 'session-project-group session-pinned-group'; @@ -8681,18 +8776,20 @@ sessionList.appendChild(pinnedGroupEl); } - projectGroups.forEach((group, groupIndex) => { - const groupKey = getProjectCollapseKey(group); - const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group); - const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions - ? { visibleSessions: group.sessions, hiddenSessions: [] } - : splitCollapsedSessions(group.sessions, oldSessionCollapseKey); - const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey); - const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId); - const hasUnreadSession = group.sessions.some((session) => session.hasUnread); - const hasRunningSession = group.sessions.some((session) => session.isRunning); - const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren); - const groupBodyId = `session-project-body-${groupIndex}`; + renderGroups.forEach((entry) => { + const { + group, + groupKey, + oldSessionCollapseKey, + visibleGroupSessions, + hiddenGroupSessions, + isCollapsed, + hasActiveSession, + hasUnreadSession, + hasRunningSession, + hasWaitingSession, + groupBodyId, + } = entry; const groupEl = document.createElement('section'); groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`; @@ -8735,11 +8832,6 @@ sessionList.appendChild(groupEl); }); - const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey(); - const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions - ? { visibleSessions: ungroupedSessions, hiddenSessions: [] } - : splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey); - for (const s of visibleUngroupedSessions) { sessionList.appendChild(createSessionListItem(s)); } @@ -8747,6 +8839,7 @@ if (hiddenUngroupedSessions.length > 0) { sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedSessions.length, ungroupedCollapseKey)); } + lastSessionListStructureSignature = structureSignature; } function startEditSessionTitle(itemEl, session) { diff --git a/public/index.html b/public/index.html index e7e1db7..bb98e0e 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -183,6 +183,6 @@ - + diff --git a/scripts/regression.js b/scripts/regression.js index f9d3b3e..47ab26f 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -619,8 +619,8 @@ function assertFrontendSidebarCollapseContract() { 'Rich themes should provide isolated rail treatments on top of the shared semantic fallback' ); assert( - indexSource.includes('style.css?v=20260727-session-item-tooltip') - && indexSource.includes('app.js?v=20260727-session-item-tooltip'), + indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm') + && indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Sidebar interaction assets should share the reviewed cache-busting version' ); } @@ -943,8 +943,8 @@ function assertPlanListProgressContract() { assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet'); assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments'); - assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Plan progress CSS should use the current cache-busted URL'); - assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Plan progress frontend logic should use the current cache-busted URL'); + assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Plan progress CSS should use the current cache-busted URL'); + assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Plan progress frontend logic should use the current cache-busted URL'); } function assertFrontendGildedThemeContract() { @@ -1059,8 +1059,8 @@ function assertFrontendGildedThemeContract() { assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels'); assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper'); assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences'); - assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); - assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Theme bundle app script should use the current cache-busted asset URL'); + assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); + assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle app script should use the current cache-busted asset URL'); } function assertFrontendWastelandThemeContract() { @@ -1313,8 +1313,8 @@ function assertFrontendWastelandThemeContract() { assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`); }); - assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); - assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Wasteland registration should share the cache-busted theme bundle URL'); + assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); + assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Wasteland registration should share the cache-busted theme bundle URL'); } function assertFrontendCcwebPromptContract() { @@ -2344,6 +2344,630 @@ function assertSessionItemTooltipContract() { assert(!createItemSource.includes('item.title = sessionCwd'), 'Session card should not fall back to a project-only tooltip'); } +function assertSidebarTitleRefreshStormContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem'); + const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList'); + const optionalSources = [ + 'buildSessionListStructureSignature', + 'refreshSessionListRelativeTimes', + ].map((name) => maybeExtractFunctionSource(frontendSource, name)).filter(Boolean).join('\n'); + + const api = new Function(` + class MiniElement { + constructor(tagName = 'div') { + this.tagName = String(tagName || 'div').toUpperCase(); + this.children = []; + this.dataset = {}; + this.attributes = {}; + this.listeners = {}; + this.parentElement = null; + this.hidden = false; + this._className = ''; + this._textContent = ''; + this._innerHTML = ''; + } + get className() { + return this._className; + } + set className(value) { + this._className = String(value || ''); + } + get classList() { + const node = this; + return { + contains(name) { + return node._className.split(/\\s+/).filter(Boolean).includes(name); + }, + add(...names) { + const classes = new Set(node._className.split(/\\s+/).filter(Boolean)); + names.filter(Boolean).forEach((name) => classes.add(name)); + node._className = [...classes].join(' '); + }, + remove(...names) { + const removeSet = new Set(names.filter(Boolean)); + node._className = node._className.split(/\\s+/).filter(Boolean).filter((name) => !removeSet.has(name)).join(' '); + }, + toggle(name, force) { + const hasClass = this.contains(name); + const shouldAdd = force === undefined ? !hasClass : !!force; + if (shouldAdd) this.add(name); + else this.remove(name); + return shouldAdd; + }, + }; + } + get childElementCount() { + return this.children.length; + } + get textContent() { + return this._textContent; + } + set textContent(value) { + this._textContent = String(value || ''); + this.children = []; + } + get innerHTML() { + return this._innerHTML; + } + set innerHTML(value) { + this._innerHTML = String(value || ''); + this.children = []; + this._parseInnerHtml(this._innerHTML); + } + _parseInnerHtml(html) { + const tagRe = /<([a-z][a-z0-9-]*)([^>]*)>/gi; + let match; + while ((match = tagRe.exec(html))) { + const tagName = match[1]; + const attrSource = match[2] || ''; + const classMatch = attrSource.match(/class="([^"]*)"/); + if (!classMatch) continue; + const child = new MiniElement(tagName); + child.className = classMatch[1]; + const titleMatch = attrSource.match(/title="([^"]*)"/); + if (titleMatch) child.title = titleMatch[1]; + const ariaExpandedMatch = attrSource.match(/aria-expanded="([^"]*)"/); + if (ariaExpandedMatch) child.setAttribute('aria-expanded', ariaExpandedMatch[1]); + const idMatch = attrSource.match(/id="([^"]*)"/); + if (idMatch) child.id = idMatch[1]; + const closeTag = ''; + const closeIndex = html.indexOf(closeTag, tagRe.lastIndex); + if (closeIndex >= 0) { + const raw = html.slice(tagRe.lastIndex, closeIndex); + child._textContent = raw.replace(/<[^>]+>/g, '').replace(/\\s+/g, ' ').trim(); + } + this.appendChild(child); + } + } + appendChild(child) { + if (child && typeof child === 'object') child.parentElement = this; + this.children.push(child); + return child; + } + setAttribute(name, value) { + this.attributes[name] = String(value); + } + getAttribute(name) { + return this.attributes[name]; + } + addEventListener(name, handler) { + if (!this.listeners[name]) this.listeners[name] = []; + this.listeners[name].push(handler); + } + dispatchEvent(event) { + const evt = event || {}; + evt.target = evt.target || this; + evt.stopPropagation = evt.stopPropagation || function stopPropagation() {}; + (this.listeners[evt.type] || []).forEach((handler) => handler(evt)); + } + matches(selector) { + return selector.split(',').some((part) => { + const classes = String(part || '').trim().match(/\\.([a-zA-Z0-9_-]+)/g)?.map((item) => item.slice(1)) || []; + return classes.length > 0 && classes.every((name) => this.classList.contains(name)); + }); + } + closest(selector) { + let node = this; + while (node) { + if (node.matches(selector)) return node; + node = node.parentElement; + } + return null; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + querySelectorAll(selector) { + const results = []; + const visit = (node) => { + if (!node || typeof node !== 'object') return; + if (node !== this && node.matches(selector)) results.push(node); + (Array.isArray(node.children) ? node.children : []).forEach(visit); + }; + visit(this); + return results; + } + } + + let sessions = []; + let currentSessionId = 's1'; + let sessionSearchQuery = ''; + let lastSessionListStructureSignature = ''; + let collapseOlderSessions = false; + let failOldSessionLoadMoreOnce = false; + let currentAgent = 'codexapp'; + let currentMode = 'yolo'; + const AGENT_LABELS = { codexapp: 'Codex' }; + const collapsedProjectKeys = new Set(); + const pendingNotesByTarget = new Map(); + const queuedMessagesByTarget = new Map(); + const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } }; + const sessionList = new MiniElement('div'); + sessionList.clearCount = 0; + Object.defineProperty(sessionList, 'innerHTML', { + get() { return this._innerHTML; }, + set(value) { + this._innerHTML = String(value || ''); + this.children = []; + if (value === '') this.clearCount += 1; + }, + }); + const document = { + createElement(tagName) { + return new MiniElement(tagName); + }, + querySelectorAll(selector) { + return sessionList.querySelectorAll(selector); + }, + }; + const Element = MiniElement; + let openedSessionIds = []; + + function syncSessionSearchUi() {} + function normalizeAgent(agent) { return AGENT_LABELS[agent] ? agent : 'codexapp'; } + function getVisibleSessions() { return sessions; } + function normalizeSessionSearchQuery(query) { return String(query || '').trim().toLowerCase(); } + function sessionMatchesSearch(session, normalizedQuery) { + return !normalizedQuery || String(session.title || '').toLowerCase().includes(normalizedQuery); + } + function getPathLeaf(input) { + const normalized = String(input || '').replace(/\\\\/g, '/').replace(/\\/+$/, ''); + return normalized.split('/').filter(Boolean).pop() || ''; + } + function getSessionEffectiveCwd(session) { return session?.cwd || ''; } + function getSessionProjectName(session) { return session?.projectName || getPathLeaf(getSessionEffectiveCwd(session)); } + function buildSessionItemTooltip(projectName, title) { + return [projectName ? '项目:' + projectName : '', '标题:' + (title || 'Untitled')].filter(Boolean).join('\\n'); + } + function escapeHtml(value) { return String(value ?? ''); } + function timeAgo(value) { return 'time:' + String(value || ''); } + function compareSessionUpdatedDesc(a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); } + function compareSessionPinnedDesc(a, b) { return new Date(b.pinnedAt || 0) - new Date(a.pinnedAt || 0); } + function splitPinnedSessions(sessionItems) { + const pinnedSessions = []; + const regularSessions = []; + for (const session of sessionItems) { + (session.pinnedAt ? pinnedSessions : regularSessions).push(session); + } + pinnedSessions.sort(compareSessionPinnedDesc); + regularSessions.sort(compareSessionUpdatedDesc); + return { pinnedSessions, regularSessions }; + } + function groupSessionsByProject(sessionItems) { + const groups = []; + const groupMap = new Map(); + const ungroupedSessions = []; + for (const session of sessionItems) { + const name = getSessionProjectName(session); + if (!name) { + ungroupedSessions.push(session); + continue; + } + if (!groupMap.has(name)) { + const group = { name, cwd: getSessionEffectiveCwd(session), sessions: [], latestUpdated: session.updated || '' }; + groupMap.set(name, group); + groups.push(group); + } + const group = groupMap.get(name); + group.sessions.push(session); + if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) { + group.latestUpdated = session.updated || group.latestUpdated; + group.cwd = getSessionEffectiveCwd(session) || group.cwd; + } + } + for (const group of groups) group.sessions.sort(compareSessionUpdatedDesc); + ungroupedSessions.sort(compareSessionUpdatedDesc); + return { groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), ungroupedSessions }; + } + function getProjectCollapseKey(group) { return normalizeAgent(currentAgent) + ':' + (group?.cwd || group?.name || ''); } + function getProjectOldSessionCollapseKey(group) { return 'project:' + getProjectCollapseKey(group); } + function getUngroupedOldSessionCollapseKey() { return normalizeAgent(currentAgent) + ':ungrouped'; } + function splitCollapsedSessions(sessionItems) { + if (!collapseOlderSessions || sessionItems.length < 2) { + return { visibleSessions: sessionItems, hiddenSessions: [] }; + } + return { visibleSessions: sessionItems.slice(0, 1), hiddenSessions: sessionItems.slice(1) }; + } + function createOldSessionLoadMoreButton() { + if (failOldSessionLoadMoreOnce) { + failOldSessionLoadMoreOnce = false; + throw new Error('synthetic old-session render failure'); + } + return new MiniElement('button'); + } + function setProjectCollapsed() {} + function quickCreateProjectSession() {} + function setSessionActionMenuOpen(item, open) { item.classList.toggle('menu-open', open); } + function closeSessionActionMenus() {} + function copyTextToClipboard() {} + function toggleSessionPinned() {} + function getLastSessionForAgent() { return ''; } + function getAgentSessionStorageKey() { return ''; } + function getSessionQueueKey(sessionId) { return sessionId ? 'session:' + sessionId : ''; } + function invalidateSessionCache() {} + function send() {} + function resetChatView() {} + const skipDeleteConfirm = true; + function showDeleteConfirm() {} + function isMobileInputMode() { return false; } + function closeSidebar() {} + function openSession(sessionId) { openedSessionIds.push(sessionId); } + function startEditSessionTitle() {} + + ${optionalSources} + ${createItemSource} + ${renderListSource} + + function cloneSession(session, overrides = {}) { + return { ...session, ...overrides }; + } + function setSessions(nextSessions) { + sessions = nextSessions.map((session) => ({ ...session })); + } + function nodesByClass(className) { + return sessionList.querySelectorAll('.' + className); + } + function timeTextFor(sessionId) { + const item = nodesByClass('session-item').find((node) => node.dataset.id === sessionId); + return item?.querySelector('.session-item-time')?.textContent || ''; + } + return { + renderSessionList, + setSessions, + setCollapseOlderSessions(value) { collapseOlderSessions = !!value; }, + failNextOldSessionLoadMore() { failOldSessionLoadMoreOnce = true; }, + cloneSession, + nodeState() { + return { + clearCount: sessionList.clearCount, + groups: nodesByClass('session-project-group'), + items: nodesByClass('session-item'), + openedSessionIds: [...openedSessionIds], + }; + }, + timeTextFor, + clickFirstSession() { + const first = nodesByClass('session-item')[0]; + first.dispatchEvent({ type: 'click', target: first }); + }, + }; + `)(); + + const baseSessions = [ + { + id: 's1', + agent: 'codexapp', + title: 'Alpha', + updated: '2026-07-30T08:00:00.000Z', + cwd: '/work/cc-web', + isRunning: false, + hasUnread: false, + waitingOnChildren: false, + readyReplyCount: 0, + pendingReplyCount: 0, + }, + { + id: 's2', + agent: 'codexapp', + title: 'Beta', + updated: '2026-07-30T07:00:00.000Z', + cwd: '/work/cc-web', + isRunning: false, + hasUnread: false, + waitingOnChildren: false, + readyReplyCount: 0, + pendingReplyCount: 0, + }, + ]; + + api.setSessions(baseSessions); + api.renderSessionList(); + const initial = api.nodeState(); + assert(initial.clearCount === 1, 'Initial sidebar render should build the DOM once'); + assert(initial.groups.length === 1, 'Initial sidebar render should create a project group'); + assert(initial.items.length === 2, 'Initial sidebar render should create session items'); + api.clickFirstSession(); + assert(api.nodeState().openedSessionIds.join(',') === 's1', 'Initial session item click listener should work'); + + api.setSessions([ + api.cloneSession(baseSessions[0], { updated: '2026-07-30T08:00:30.000Z' }), + baseSessions[1], + ]); + api.renderSessionList(); + const afterUpdatedOnly = api.nodeState(); + assert(afterUpdatedOnly.clearCount === 1, 'Updated-only sidebar snapshots should not clear the list again'); + assert(afterUpdatedOnly.groups[0] === initial.groups[0], 'Updated-only sidebar snapshots should keep project group node identity'); + assert(afterUpdatedOnly.items[0] === initial.items[0], 'Updated-only sidebar snapshots should keep session item node identity'); + assert(api.timeTextFor('s1') === 'time:2026-07-30T08:00:30.000Z', 'Updated-only sidebar snapshots should refresh relative time in place'); + api.clickFirstSession(); + assert(api.nodeState().openedSessionIds.join(',') === 's1,s1', 'Updated-only sidebar snapshots should keep existing click listener usable'); + + api.setSessions([ + api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z' }), + baseSessions[1], + ]); + api.renderSessionList(); + const afterTitle = api.nodeState(); + assert(afterTitle.clearCount === 2, 'Title changes should still rebuild the sidebar structure'); + assert(afterTitle.items[0] !== afterUpdatedOnly.items[0], 'Title changes should replace the affected session node'); + + api.setSessions([ + api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }), + baseSessions[1], + ]); + api.renderSessionList(); + const afterStatus = api.nodeState(); + assert(afterStatus.clearCount === 3, 'Running status changes should still rebuild the sidebar structure'); + + api.setSessions([ + api.cloneSession(baseSessions[1], { updated: '2026-07-30T09:00:00.000Z' }), + api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }), + ]); + api.renderSessionList(); + const afterOrder = api.nodeState(); + assert(afterOrder.clearCount === 4, 'Order changes should still rebuild the sidebar structure'); + assert(afterOrder.items[0].dataset.id === 's2', 'Order changes should render the new first session in place'); + + api.setCollapseOlderSessions(true); + api.renderSessionList(); + const afterOldSessionCollapse = api.nodeState(); + assert(afterOldSessionCollapse.clearCount === 5, 'Old-session collapse changes should rebuild without losing its collapse key'); + assert(afterOldSessionCollapse.items.length === 1, 'Old-session collapse should keep only the recent project session visible'); + + api.setSessions([ + api.cloneSession(baseSessions[0], { id: 'pinned', title: 'Pinned', pinnedAt: '2026-07-30T10:00:00.000Z' }), + api.cloneSession(baseSessions[0], { title: 'Alpha regular' }), + baseSessions[1], + ]); + api.failNextOldSessionLoadMore(); + let syntheticRenderFailed = false; + try { + api.renderSessionList(); + } catch (err) { + syntheticRenderFailed = err?.message === 'synthetic old-session render failure'; + } + assert(syntheticRenderFailed, 'The regression harness should exercise a partial sidebar render failure'); + api.renderSessionList(); + const afterRenderRetry = api.nodeState(); + assert(afterRenderRetry.groups.length === 2, 'A render retry should rebuild both pinned and project groups after a partial failure'); + assert(afterRenderRetry.items.length === 2, 'A render retry should not accept a partial pinned-only DOM as complete'); +} + +function assertCcwebMcpChildUpdateCoalescingContract() { + const source = fs.readFileSync(SERVER_PATH, 'utf8'); + const functionNames = [ + 'isFinalCcwebMcpChildStatus', + 'snapshotCcwebMcpChildForPersist', + 'flushPendingCcwebMcpChildSession', + 'updateCcwebMcpChildToolState', + 'updatePersistedCcwebMcpChildTool', + 'flushCcwebMcpChildSessionListBroadcast', + 'scheduleCcwebMcpChildSessionListBroadcast', + 'sendCcwebMcpChildAgentUpdate', + ]; + const helperSource = functionNames.map((name) => extractFunctionSource(source, name)).join('\n'); + const api = new Function(` + const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250; + const pendingCcwebMcpChildSessionFlushes = new Map(); + let ccwebMcpChildSessionListBroadcastTimer = null; + const activeCodexAppTurns = new Map(); + const diskSessions = new Map(); + const scheduledTimers = []; + const sentPayloads = []; + const targetWs = { readyState: 1 }; + let loadCount = 0; + let saveCount = 0; + let broadcastCount = 0; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + function setTimeout(callback) { + const timer = { callback, active: true, unref() {} }; + scheduledTimers.push(timer); + return timer; + } + function clearTimeout(timer) { + if (timer) timer.active = false; + } + function loadSession(sessionId) { + loadCount += 1; + const session = diskSessions.get(sessionId); + return session ? clone(session) : null; + } + function saveSession(session) { + saveCount += 1; + diskSessions.set(session.id, clone(session)); + return true; + } + function findViewingSessionWs() { + return targetWs; + } + function findCcwebMcpChildTargetToolInToolCalls(toolCalls, spawnToolId) { + return (Array.isArray(toolCalls) ? toolCalls : []).find((tool) => tool?.id === spawnToolId) || null; + } + function findCcwebMcpChildTargetToolInMessages(messages, spawnToolId) { + for (const message of Array.isArray(messages) ? messages : []) { + const tool = findCcwebMcpChildTargetToolInToolCalls(message?.toolCalls, spawnToolId); + if (tool) return tool; + } + return null; + } + function mergeCcwebMcpChildIntoTool(tool, child) { + if (!tool || !child) return null; + const result = tool.result ? JSON.parse(tool.result) : {}; + const agentsStates = result.agentsStates || {}; + agentsStates[child.threadId] = { + ...(agentsStates[child.threadId] || {}), + status: child.status, + planCurrentStep: child.planCurrentStep || '', + finalMessage: child.finalMessage || '', + }; + tool.result = JSON.stringify({ ...result, status: child.status, agentsStates }); + tool.done = isFinalCcwebMcpChildStatus(child.status); + return tool; + } + function ccwebMcpChildPublicState(child) { + return { ...child }; + } + function wsSend(ws, payload) { + sentPayloads.push({ ws, payload: clone(payload) }); + } + function broadcastSessionList() { + broadcastCount += 1; + } + + ${helperSource} + + function flushTimers() { + let progressed = true; + while (progressed) { + progressed = false; + for (const timer of scheduledTimers) { + if (!timer.active) continue; + timer.active = false; + timer.callback(); + progressed = true; + } + } + } + return { + seed(session, activeTool) { + diskSessions.set(session.id, clone(session)); + activeCodexAppTurns.set(session.id, { ws: targetWs, toolCalls: [activeTool] }); + }, + send: sendCcwebMcpChildAgentUpdate, + flushTimers, + snapshot() { + return { + loadCount, + saveCount, + broadcastCount, + payloadCount: sentPayloads.length, + diskSessions: clone([...diskSessions.entries()]), + }; + }, + }; + `)(); + + const persistedTool = { + id: 'spawn-child-1', + name: 'subAgentActivity', + kind: 'collab_agent_tool_call', + input: '{}', + result: JSON.stringify({ agentsStates: {} }), + done: false, + }; + api.seed({ + id: 'parent-session', + title: 'Parent', + updated: '2026-07-30T08:00:00.000Z', + messages: [{ role: 'assistant', content: '', toolCalls: [persistedTool] }], + }, { ...persistedTool }); + + for (const planCurrentStep of ['分析', '实现', '验证']) { + api.send('parent-session', { + threadId: 'child-1', + spawnToolId: 'spawn-child-1', + status: 'running', + planCurrentStep, + }); + } + const duringBurst = api.snapshot(); + assert(duringBurst.payloadCount === 3, 'Every child delta should still send a realtime local payload'); + assert(duringBurst.loadCount === 1, 'A child update burst should reuse one pending parent session snapshot'); + assert(duringBurst.saveCount === 0, 'A running child update burst should defer parent session persistence'); + assert(duringBurst.broadcastCount === 0, 'A running child update burst should defer full session-list broadcasts'); + + api.flushTimers(); + const afterBurst = api.snapshot(); + assert(afterBurst.saveCount === 1, 'A child update burst should persist the parent session once'); + assert(afterBurst.loadCount <= 2, 'A child update burst should not reload the parent session for every delta'); + assert(afterBurst.broadcastCount === 1, 'A child update burst should broadcast the full session list once'); + const storedAfterBurst = new Map(afterBurst.diskSessions).get('parent-session'); + const storedBurstState = JSON.parse(storedAfterBurst.messages[0].toolCalls[0].result).agentsStates['child-1']; + assert(storedBurstState.planCurrentStep === '验证', 'The trailing flush should persist the latest child state'); + + api.send('parent-session', { + threadId: 'child-1', + spawnToolId: 'spawn-child-1', + status: 'returned', + planCurrentStep: '完成', + finalMessage: '最终结果', + }); + const afterFinal = api.snapshot(); + assert(afterFinal.payloadCount === 4, 'A final child update should still send its realtime local payload'); + assert(afterFinal.saveCount === 2, 'A final child update should flush persistence immediately'); + assert(afterFinal.broadcastCount === 2, 'A final child update should flush the session-list broadcast immediately'); + const storedFinal = new Map(afterFinal.diskSessions).get('parent-session'); + const storedFinalState = JSON.parse(storedFinal.messages[0].toolCalls[0].result).agentsStates['child-1']; + assert(storedFinalState.status === 'returned' && storedFinalState.finalMessage === '最终结果', 'The final child state must not be lost'); + + api.flushTimers(); + const afterCancelledTimers = api.snapshot(); + assert(afterCancelledTimers.saveCount === 2 && afterCancelledTimers.broadcastCount === 2, 'An immediate final flush should cancel stale trailing work'); + + const sharedSpawnTool = { + id: 'spawn-shared', + name: 'subAgentActivity', + kind: 'collab_agent_tool_call', + input: '{}', + result: JSON.stringify({ agentsStates: {} }), + done: false, + }; + api.seed({ + id: 'sibling-parent-session', + title: 'Sibling parent', + updated: '2026-07-30T08:00:00.000Z', + messages: [{ role: 'assistant', content: '', toolCalls: [sharedSpawnTool] }], + }, { ...sharedSpawnTool }); + const beforeSiblingBurst = api.snapshot(); + api.send('sibling-parent-session', { + threadId: 'sibling-a', + spawnToolId: 'spawn-shared', + status: 'running', + planCurrentStep: 'A 验证', + }); + api.send('sibling-parent-session', { + threadId: 'sibling-b', + spawnToolId: 'spawn-shared', + status: 'running', + planCurrentStep: 'B 验证', + }); + const duringSiblingBurst = api.snapshot(); + assert(duringSiblingBurst.payloadCount === beforeSiblingBurst.payloadCount + 2, 'Sibling child deltas should both remain realtime'); + assert(duringSiblingBurst.saveCount === beforeSiblingBurst.saveCount, 'Sibling child deltas should share the pending parent flush'); + api.flushTimers(); + const afterSiblingBurst = api.snapshot(); + assert(afterSiblingBurst.saveCount === beforeSiblingBurst.saveCount + 1, 'Sibling child deltas should persist in one parent save'); + assert(afterSiblingBurst.broadcastCount === beforeSiblingBurst.broadcastCount + 1, 'Sibling child deltas should share one full-list broadcast'); + const storedSiblingSession = new Map(afterSiblingBurst.diskSessions).get('sibling-parent-session'); + const storedSiblingStates = JSON.parse(storedSiblingSession.messages[0].toolCalls[0].result).agentsStates; + assert(storedSiblingStates['sibling-a']?.planCurrentStep === 'A 验证', 'The first sibling sharing a spawn tool must not be dropped'); + assert(storedSiblingStates['sibling-b']?.planCurrentStep === 'B 验证', 'The second sibling sharing a spawn tool must be persisted'); +} + function assertTitleHistoryOutlineContract() { const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); @@ -3508,6 +4132,10 @@ function extractFunctionSource(source, name) { throw new Error(`Could not parse function body for ${name}`); } +function maybeExtractFunctionSource(source, name) { + return source.indexOf(`function ${name}(`) >= 0 ? extractFunctionSource(source, name) : ''; +} + function assertCodexAppChildToolRoutingContract() { const source = fs.readFileSync(SERVER_PATH, 'utf8'); const helperStart = source.indexOf('function parseMaybeJsonObject(value)'); @@ -3516,6 +4144,9 @@ function assertCodexAppChildToolRoutingContract() { const helperSource = source.slice(helperStart, helperEnd); const api = new Function(` const sessions = new Map(); + const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250; + const pendingCcwebMcpChildSessionFlushes = new Map(); + let ccwebMcpChildSessionListBroadcastTimer = null; let savedSession = null; function truncateTextValue(value, maxLength, suffix = '...') { const text = String(value || ''); @@ -3531,11 +4162,13 @@ function assertCodexAppChildToolRoutingContract() { function findViewingSessionWs() { return null; } + function broadcastSessionList() {} ${helperSource} return { setSession: (session) => sessions.set(session.id, session), getSession: (sessionId) => sessions.get(sessionId), getSavedSession: () => savedSession, + flushPendingCcwebMcpChildSession, updatePersistedCcwebMcpChildTool, }; `)(); @@ -3616,6 +4249,7 @@ function assertCodexAppChildToolRoutingContract() { assert(result.agentsStates?.['current-plan-thread']?.planProgress?.completed === 2, 'Exact child card should receive plan progress'); assert(result.agentsStates?.['current-plan-thread']?.planCurrentStep === '更新当前卡片', 'Exact child card should receive the current plan step'); assert(!JSON.parse(oldTool.result).agentsStates?.['current-plan-thread'], 'Older unrelated child cards must remain untouched'); + api.flushPendingCcwebMcpChildSession(session.id); assert(api.getSavedSession()?.id === session.id, 'Exact child-card merge should save the session'); const currentResultBeforeMissingId = currentTool.result; @@ -3874,6 +4508,12 @@ async function main() { console.log('Session item tooltip regression checks passed.'); return; } + if (regressionTarget === 'sidebar-title-refresh-storm') { + assertSidebarTitleRefreshStormContract(); + assertCcwebMcpChildUpdateCoalescingContract(); + console.log('Sidebar title refresh storm regression checks passed.'); + return; + } if (regressionTarget === 'windows-startup') { assertWindowsStartupContract(); console.log('Windows startup regression checks passed.'); @@ -3904,6 +4544,7 @@ async function main() { assertFrontendPrimaryCodexAppUiContract(); assertSetTitleMcpContract(); assertSessionItemTooltipContract(); + assertCcwebMcpChildUpdateCoalescingContract(); assertTitleHistoryOutlineContract(); assertSessionSwitchResilienceContract(); assertSessionSwitchRaceContract(); @@ -5502,6 +6143,8 @@ async function main() { const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions'); const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId); assert(importedCodexItem, 'Codex session listing failed'); + const codexSubagentItem = codexSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId); + assert(!codexSubagentItem, 'Codex import list should hide subagent rollout threads'); ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath })); const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt'); @@ -5518,7 +6161,7 @@ async function main() { assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation'); assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count'); const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId); - assert(objectSourceItem?.source === 'subagent', 'Codex App import list should format object source metadata'); + assert(!objectSourceItem, 'Codex App import list should hide subagent rollout threads'); ws.send(JSON.stringify({ type: 'import_codex_session', diff --git a/server.js b/server.js index 182dc21..aacfc91 100644 --- a/server.js +++ b/server.js @@ -698,6 +698,10 @@ let codexAppClient = null; let codexAppClientSignature = ''; const CODEX_APP_STATE_FILE = 'codexapp-state.json'; const CODEX_APP_STATE_FLUSH_DELAY_MS = 250; +const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = CODEX_APP_STATE_FLUSH_DELAY_MS; +// 同一父会话的 child 增量共用一个短周期待保存快照,避免每条增量都读写整份会话。 +const pendingCcwebMcpChildSessionFlushes = new Map(); +let ccwebMcpChildSessionListBroadcastTimer = null; // Track which session each ws is viewing: ws -> sessionId const wsSessionMap = new Map(); @@ -8917,10 +8921,12 @@ function mergeCcwebMcpChildIntoTool(tool, child) { }; } -function updateCcwebMcpChildToolState(sessionId, child) { +function updateCcwebMcpChildToolState(sessionId, child, persistedTool = null) { const entry = activeCodexAppTurns.get(sessionId) || null; let tool = findCcwebMcpChildTargetToolInToolCalls(entry?.toolCalls, child.spawnToolId); + // 持久化快照已经完成合并时直接复用,避免没有活动 turn 时再次加载父会话。 + if (!tool && persistedTool) return persistedTool; if (!tool) { const session = loadSession(sessionId); const messages = Array.isArray(session?.messages) ? session.messages : []; @@ -8929,20 +8935,105 @@ function updateCcwebMcpChildToolState(sessionId, child) { return mergeCcwebMcpChildIntoTool(tool, child); } +function isFinalCcwebMcpChildStatus(status) { + return status === 'returned' || status === 'failed' || status === 'interrupted' || status === 'closed'; +} + +function snapshotCcwebMcpChildForPersist(child = {}) { + return { + ...child, + planProgress: child.planProgress && typeof child.planProgress === 'object' + ? { ...child.planProgress } + : child.planProgress || null, + }; +} + +function flushPendingCcwebMcpChildSession(sessionId) { + const pending = pendingCcwebMcpChildSessionFlushes.get(sessionId); + if (!pending) return false; + if (pending.timer) clearTimeout(pending.timer); + pendingCcwebMcpChildSessionFlushes.delete(sessionId); + + // 尾随冲刷前重新读取最新会话,避免覆盖这 250ms 内由父 turn 完成等路径写入的数据。 + const session = loadSession(sessionId) || pending.session; + if (!session || !Array.isArray(session.messages)) return false; + let merged = false; + for (const child of pending.children.values()) { + const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId); + if (mergeCcwebMcpChildIntoTool(targetTool, child)) merged = true; + } + if (!merged) return false; + + if (!session.updated || pending.updated > session.updated) session.updated = pending.updated; + if (pending.markUnread) session.hasUnread = true; + return saveSession(session); +} + function updatePersistedCcwebMcpChildTool(sessionId, child) { - const session = loadSession(sessionId); - if (!session || !Array.isArray(session.messages)) return null; - const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId); - if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null; - session.updated = new Date().toISOString(); - if (!findViewingSessionWs(sessionId)) session.hasUnread = true; - saveSession(session); + let pending = pendingCcwebMcpChildSessionFlushes.get(sessionId) || null; + const createdPending = !pending; + if (!pending) { + const session = loadSession(sessionId); + if (!session || !Array.isArray(session.messages)) return null; + pending = { + session, + children: new Map(), + updated: session.updated || '', + markUnread: false, + timer: null, + }; + pendingCcwebMcpChildSessionFlushes.set(sessionId, pending); + } + + const targetTool = findCcwebMcpChildTargetToolInMessages(pending.session.messages, child.spawnToolId); + if (!mergeCcwebMcpChildIntoTool(targetTool, child)) { + if (createdPending) pendingCcwebMcpChildSessionFlushes.delete(sessionId); + return null; + } + + // 同一可见协作工具下可能挂多个 sibling/nested child,必须按线程分别保留最新状态。 + const childKey = String(child.threadId || child.spawnToolId || '').trim(); + pending.children.set(childKey, snapshotCcwebMcpChildForPersist(child)); + pending.updated = new Date().toISOString(); + if (!findViewingSessionWs(sessionId)) pending.markUnread = true; + + if (isFinalCcwebMcpChildStatus(child.status)) { + flushPendingCcwebMcpChildSession(sessionId); + } else if (!pending.timer) { + pending.timer = setTimeout(() => { + flushPendingCcwebMcpChildSession(sessionId); + }, CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS); + if (typeof pending.timer.unref === 'function') pending.timer.unref(); + } return targetTool; } +function flushCcwebMcpChildSessionListBroadcast() { + if (ccwebMcpChildSessionListBroadcastTimer) { + clearTimeout(ccwebMcpChildSessionListBroadcastTimer); + ccwebMcpChildSessionListBroadcastTimer = null; + } + broadcastSessionList(); +} + +function scheduleCcwebMcpChildSessionListBroadcast(options = {}) { + if (options.immediate) { + flushCcwebMcpChildSessionListBroadcast(); + return; + } + if (ccwebMcpChildSessionListBroadcastTimer) return; + ccwebMcpChildSessionListBroadcastTimer = setTimeout(() => { + ccwebMcpChildSessionListBroadcastTimer = null; + broadcastSessionList(); + }, CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS); + if (typeof ccwebMcpChildSessionListBroadcastTimer.unref === 'function') { + ccwebMcpChildSessionListBroadcastTimer.unref(); + } +} + function sendCcwebMcpChildAgentUpdate(sessionId, child) { - const activeTool = updateCcwebMcpChildToolState(sessionId, child); const persistedTool = updatePersistedCcwebMcpChildTool(sessionId, child); + const activeTool = updateCcwebMcpChildToolState(sessionId, child, persistedTool); const tool = activeTool || (persistedTool ? { id: persistedTool.id, name: persistedTool.name, @@ -8961,7 +9052,7 @@ function sendCcwebMcpChildAgentUpdate(sessionId, child) { }; const targetWs = activeCodexAppTurns.get(sessionId)?.ws || findViewingSessionWs(sessionId); if (targetWs) wsSend(targetWs, payload); - broadcastSessionList(); + scheduleCcwebMcpChildSessionListBroadcast({ immediate: isFinalCcwebMcpChildStatus(child.status) }); } function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { @@ -10910,6 +11001,8 @@ function handleListCodexSessions(ws, msg = {}) { for (const filePath of getCodexRolloutFiles()) { const parsed = parseCodexRolloutFile(filePath); if (!parsed?.meta?.threadId) continue; + const source = codexImportSourceLabel(parsed.meta.source); + if (source === 'subagent') continue; if (seen.has(parsed.meta.threadId)) continue; seen.add(parsed.meta.threadId); const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20); @@ -10925,7 +11018,7 @@ function handleListCodexSessions(ws, msg = {}) { cwd: parsed.meta.cwd || null, updatedAt: parsed.meta.updatedAt || null, cliVersion: parsed.meta.cliVersion || '', - source: codexImportSourceLabel(parsed.meta.source), + source, sourceConversationId: sourceConversation?.id || null, sourceConversationTitle: sourceConversation?.title || '', duplicateCount: 1,