diff --git a/.planning/2026-09-13-session-switch-diagnosis/findings.md b/.planning/2026-09-13-session-switch-diagnosis/findings.md new file mode 100644 index 0000000..d8a8db8 --- /dev/null +++ b/.planning/2026-09-13-session-switch-diagnosis/findings.md @@ -0,0 +1,55 @@ +# 切换对话诊断发现 + +## 当前已知 + +- 会话文件仍包含用户消息,暂未发现数据层删除。 +- 切换通过 WebSocket `load_session` 请求完成,刷新会创建新连接。 +- 服务端日志存在大量心跳终止和断线重连记录。 +- 前端同时处理会话缓存、加载请求代次、历史分页、运行态恢复和滚动位置。 + +## 可复核证据 + +- `server.js:5656-5658` 普通加载只发送最近 `INITIAL_HISTORY_COUNT = 12` 条; + `server.js:10890-10907` 同时发送完整 `historyTotal`、`historyCursor`、 + `historyBaseIndex` 和 `historyTruncated`,普通大历史请求可以出现 + `messages=12, historyTotal=99, historyCursor=87, historyPending=false`。 +- 原逻辑 `public/app.js:2607-2611` 用 `!payload.historyPending` 推导 + `complete`,因此上述部分快照会被标成完整;随后 + `session_info → cacheSessionSnapshot → getSessionCacheDisposition → + showCachedSession` 可在 A → B → A 时绕过服务端重新加载。 +- 修复后 `normalizeSessionSnapshot` 和 `isCompleteSessionSnapshot` 共同要求 + 游标归零、未截断、未等待分片且缓冲数覆盖总数;缓存写入和读取都复用同一 + 谓词。历史分片按 `historyBaseIndex` 合并,最后一页才转换为完整缓存。 +- 部分快照现在进入独立 `sessionHistoryBuffers`,仅供后续 `load_history_page` + 合并;`getSessionCacheDisposition` 永远不会把它当作可展示缓存。分页游标归 + 零后才调用 `cacheSessionSnapshot`,因此 A → B → A 不会复用半截历史。 +- `logs/process.log` 中存在 `ws_heartbeat_terminate`,例如 + `2026-09-13T07:30:03.445Z`,`missedPongs=4`、`lastActivityAgeMs=150471`; + `server.js:2216-2221` 的 `markWsActivity` 已会把 `isAlive` 恢复为 `true`, + 因此该记录更符合浏览器/反向代理长期未返回 pong 的半断连接,而不是服务端 + 漏置位。 +- 客户端原先只在 `onclose` 触发重连;现增加 `WS_CLIENT_HEARTBEAT_INTERVAL_MS` + (15 秒)与 `WS_CLIENT_HEARTBEAT_TIMEOUT_MS`(10 秒),通过 + `client_heartbeat → client_heartbeat_ack` 主动识别浏览器仍显示 `OPEN` 的半断, + 再复用现有 `onclose` 流程重放切换请求。 + +## 问题边界 + +- 会话文件未发现消息被删除;气泡消失的直接原因是前端错误缓存/重绘, + 不是数据库或服务端历史丢失。 +- WebSocket 处于浏览器 `OPEN` 但链路半断时,`send(load_session)` 可能无效; + 刷新页面会强制新建连接。现在客户端最多约 25 秒主动发现并重连,真实反向 + 代理是否丢弃控制帧仍需浏览器现场或代理配置验证。 +- 未重启现有 `ccweb`,因为当前会话列表中还有另一个 running 对话;源码修复 + 需在安全窗口重启后才会进入线上进程。 + +## 2026-09-13 新增线上证据 + +- 用户截图中的 `Unknown type: client_heartbeat` 证实线上 PM2 仍运行未包含 + `client_heartbeat` 分支的旧 `server.js`;前端先部署心跳会被旧服务端当作业务 + 错误,并触发连接重连循环。 +- `mcp__ccweb__ccweb_list_conversations(status=running)` 显示当前会话之外仍有 + 一个 running 会话,按项目运维约定本轮不能重启 PM2。 +- 已在前端加入 `auth_result.features.clientHeartbeat` 能力闸门:旧服务端不声明 + 时不发送心跳;即使旧页面已发送并收到 Unknown type,也会静默停用心跳,不再 + 将协议探测错误显示到聊天区。新版服务端已声明该能力,安全重启后可恢复探测。 diff --git a/.planning/2026-09-13-session-switch-diagnosis/progress.md b/.planning/2026-09-13-session-switch-diagnosis/progress.md new file mode 100644 index 0000000..22c97d9 --- /dev/null +++ b/.planning/2026-09-13-session-switch-diagnosis/progress.md @@ -0,0 +1,10 @@ +# 诊断进度 + +- 2026-09-13:承接既有只读排查结果,创建本轮诊断记录;未修改业务代码、未重启服务。 +- 2026-09-13:补充部分历史快照缓存回归契约;修改前因缺少统一完整性判断而失败,进入最小前端修复阶段。 +- 2026-09-13:修复 `normalizeSessionSnapshot`、缓存判定和历史分片收口;局部回归与 Node 语法检查通过,尚未重启服务。 +- 2026-09-13:复核确认服务端 `markWsActivity` 已正确处理 pong,未保留冗余服务端改动;定向回归、全量回归和 `git diff --check` 均通过。因存在其他 running 对话,未执行 pm2 重启。 +- 2026-09-13:补充旧坏缓存、A→B→A 切换和“部分快照→连续分页→strong cache”回归;完整性谓词显式要求 `historyBaseIndex=0`,并移除 `normalizeSessionSnapshot` 的强制 complete 覆盖入口。 +- 2026-09-13:增加客户端应用层心跳与服务端应答,半断连接不再只能等待 45 秒加载超时;定向回归、全量回归、语法检查和 `git diff --check` 再次通过。 +- 2026-09-13:根据用户截图确认线上旧服务端返回 `Unknown type: client_heartbeat`;增加服务端能力声明与前端能力闸门,并对旧协议错误做静默兼容。当前仍有其他 running 会话,未执行 PM2 重启。 +- 2026-09-13:补充断线期间的自动重试提示,并让高亮收口最多保留一个 `.session-item.active`;Node 语法、定向回归及重新执行的完整回归均通过。线上 PM2 仍保持不重启。 diff --git a/.planning/2026-09-13-session-switch-diagnosis/task_plan.md b/.planning/2026-09-13-session-switch-diagnosis/task_plan.md new file mode 100644 index 0000000..383efbe --- /dev/null +++ b/.planning/2026-09-13-session-switch-diagnosis/task_plan.md @@ -0,0 +1,49 @@ +# 修复切换对话消息气泡消失计划 + +## 目标 + +修复会话切换时“用户消息气泡暂时消失”的前端缓存状态错误,并保留对 +WebSocket 半断导致切换超时的证据与边界;通过回归测试证明部分历史快照 +不会再被当作完整会话缓存。服务不重启,避免影响其他运行中的对话。 + +## 验收标准 + +- 会话 A 历史大于初始窗口时,`session_info` 的 `historyCursor > 0`、 + `historyPending = false` 快照不能命中 strong cache;A → B → A 必须重新 + 请求 `load_session`,不能只显示最近窗口。 +- 完整快照必须同时满足:`complete = true`、`historyPending = false`、 + `historyCursor = 0`、`historyBaseIndex = 0`、`historyTruncated = false`、 + `historyBuffered >= historyTotal`。 +- 历史分片按稳定消息索引合并;只有最后一页使游标归零时才写入完整缓存, + 旧分页或旧切换响应不能污染当前会话。 +- 完整快照仍可 strong cache 命中;部分快照不会直接渲染为缓存会话。 +- 客户端应用层心跳每 15 秒探测一次,连续 10 秒未收到服务端应答就主动关闭 + 当前连接并复用现有重连/切换请求重放;服务端原生 pong 心跳仍负责底层连接。 + 真实代理半断链路不在本轮浏览器自动化模拟范围。 + +## 阶段 + +- [完成] 1. 核对会话数据、WebSocket 协议和缓存根因 +- [完成] 2. 增加部分历史快照缓存回归测试 +- [完成] 3. 修复快照完整性判定与完整加载收口 +- [完成] 4. 运行静态检查和回归测试 +- [完成] 5. 汇总运行态限制、根因和剩余风险 +- [完成] 6. 修复旧服务端与新版前端心跳协议不兼容,并补充断线提示/单一高亮收口 + +## 验证命令 + +```bash +node --check public/app.js +node --check server.js +node --check scripts/regression.js +node scripts/regression.js --target session-switch-race +node scripts/regression.js --target history-recall +node scripts/regression.js --target codexapp-stale-running +node scripts/regression.js +git diff --check +``` + +## 错误记录 + +| 旧版 PM2 服务端不认识 `client_heartbeat` | 前端先于服务端重启上线心跳 | 增加 `auth_result.features.clientHeartbeat` 能力闸门,并静默兼容旧错误 | +| 完整回归首次出现 `historyLoadMore is not defined` | 首次运行时测试进程与临时服务异常退出,定向回归可复现通过 | 重新运行完整回归已通过,未发现新的历史控件故障 | diff --git a/.planning/full-outline-history/findings.md b/.planning/full-outline-history/findings.md new file mode 100644 index 0000000..d998823 --- /dev/null +++ b/.planning/full-outline-history/findings.md @@ -0,0 +1,10 @@ +# 发现记录 + +## 2026-09-13 + +- “定位”按钮对应 `user-outline-panel`;`buildUserOutlineItems()` 当前只扫描 `messagesDiv.querySelectorAll('.msg.user[data-message-id]')`,因此只包含已渲染的最近消息。 +- `session_info` 已携带 `historyCursor` / `historyTotal`,服务端 `load_history_page` 可按 `before` 返回旧消息页;现有前端收到该响应后会无条件 `prependHistoryMessages()`,所以不能直接用现有手动分页状态填充定位列表。 +- 旧消息点击定位需要同时处理两类目标:已渲染消息直接滚动;未渲染消息先通过消息索引加载对应页,再将聊天区滚动到目标。完整索引缓存不能依赖 DOM 元素 ID。 +- 定位面板本身已有独立 `max-height` 和 `overflow-y: auto`,不会因为完整索引而扩大聊天区;需要保留该隔离边界。 +- 旧消息点击定位使用独立的 `load_history_page` 目标页请求:按消息索引只取包含目标的一页,进入聊天区后再滚动到目标;完整定位索引请求只保存用户消息摘要,不渲染旧消息。 +- `session_history_chunk` 当前按 `activeHistoryPageRequest` 分支后统一调用 `prependHistoryMessages()`;独立定位请求必须用独立 request 状态在该分支前截获,否则会破坏“隐藏历史不占聊天空间”的要求。 diff --git a/.planning/full-outline-history/progress.md b/.planning/full-outline-history/progress.md new file mode 100644 index 0000000..72c2b15 --- /dev/null +++ b/.planning/full-outline-history/progress.md @@ -0,0 +1,19 @@ +# 进度日志 + +## 2026-09-13 + +- 已定位问题:定位列表只读当前聊天 DOM,45 条窗口之外的旧用户消息被完全遗漏。 +- 已确认修复边界:完整历史只进入独立索引;聊天 DOM 和消息滚动空间保持最近窗口策略。 +- 已确认旧条目点击应走独立目标页请求;完整定位请求必须在 `session_history_chunk` 中提前截获,避免调用 `prependHistoryMessages`。 +- 新增失败回归:要求独立定位索引状态、分页请求函数、旧页拦截分支和按 `data-message-index` 定位入口;修改前按预期失败。 +- 已实现摘要索引、定位专用分页请求、旧页不渲染分支、面板加载/重试状态,以及隐藏条目按消息索引重新加载。 +- 全量 `npm run regression` 首次因历史处理器回归夹具缺少新状态桩失败,已补齐夹具;第二次完整回归通过。 +- 旧条目点击使用独立 `load_history_page` 目标页请求,只在用户明确点击时把目标所在页加载进聊天区;定位列表后台索引不会改变聊天 DOM 高度。 +- 最终验证通过:`npm run regression`、`node --check public/app.js`、`node --check scripts/regression.js`、`git diff --check`。 +- 目标页响应与完整定位分页均按独立 requestId 分支处理;断线和错误会清理悬挂请求,避免后续历史响应误消费。 +- 补齐无近期用户消息的边界:只要仍有可用旧历史,定位按钮保持可打开,以便触发完整索引分页。 +- 根据实际截图修正历史入口布局:`history-load-more` 移入 `#messages` 的首位,初始停在底部时不悬浮;滚到消息顶部才显示,继续滚动时随内容离开。 +- 为消息重绘和历史前插补充控件保留/插入锚点,避免重绘时丢失历史入口或把消息插到控件上方。 +- 用户进一步明确交互:不是“跟历史内容一起在当前视口显示”,而是“仅作为消息内容首行,滚到最顶才露出”;当前 DOM/CSS 已按此语义实现。 +- 运行中服务静态核验通过:`/app.js` 含定位目标页逻辑,`/index.html` 使用 `20260913-history-control-position` 样式版本,刷新即可生效。 +- 临时 `补齐定位列表完整历史 TO DO list.csv` 已清理;计划文件保留用于追踪本次设计决策。 diff --git a/.planning/full-outline-history/task_plan.md b/.planning/full-outline-history/task_plan.md new file mode 100644 index 0000000..8f9d511 --- /dev/null +++ b/.planning/full-outline-history/task_plan.md @@ -0,0 +1,49 @@ +# 补齐定位列表的完整历史索引 + +## 目标 + +让“定位”列表展示当前会话的全部用户消息,即使聊天区只保留最近 45 条用于显示;旧消息只进入定位索引,不重新插入聊天 DOM,也不额外占用聊天滚动空间。 + +## 阶段 + +| 阶段 | 状态 | 验收标准 | +|---|---|---| +| 1. 核对定位列表与历史分页的数据链路 | complete | 明确当前定位数据仅来自 DOM,并确认可复用的历史分页响应 | +| 2. 补充完整定位索引的失败回归 | complete | 回归能证明隐藏旧消息时定位列表仍应获得完整用户消息 | +| 3. 增加独立的定位历史索引缓存 | complete | 打开定位列表时可按页读取完整历史,且不改变聊天消息 DOM | +| 4. 支持旧消息定位加载与滚动 | complete | 点击未渲染旧消息会按消息索引加载所需页并滚动到目标 | +| 5. 运行定向回归、语法和差异检查 | complete | 定向回归、JS 语法检查、git diff --check 全部通过 | +| 6. 清理临时记录并交付修改结果 | complete | TODO CSV 删除,明确完整列表与聊天空间隔离的实现和验证结果 | + +## 机器可读阶段状态 + +### Phase 1:核对定位列表与历史分页的数据链路 +**Status:** complete + +### Phase 2:补充完整定位索引的失败回归 +**Status:** complete + +### Phase 3:增加独立的定位历史索引缓存 +**Status:** complete + +### Phase 4:支持旧消息定位加载与滚动 +**Status:** complete + +### Phase 5:运行定向回归、语法和差异检查 +**Status:** complete + +### Phase 6:清理临时记录并交付修改结果 +**Status:** complete + +## 关键决策 + +- 不改变聊天区“最近 45 条 + 手动回看”的显示策略。 +- 不把定位列表的完整历史通过隐藏 DOM 塞进消息滚动区;旧消息只保存在轻量索引对象中。 +- 复用 `load_history_page` 的现有分页协议,新增请求标识分支,避免与用户点击“查看更早消息”的聊天分页互相抢状态。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---|---| +| 定向历史回归按预期失败:缺少 `currentOutlineHistoryState` | 1 | 证明旧实现没有独立定位索引;已补充失败契约后进入实现 | +| 全量回归提取历史处理器时报 `activeOutlineHistoryRequest is not defined` | 1 | 为现有历史处理器回归夹具补充独立定位状态和合并函数桩;随后全量回归通过 | diff --git a/.planning/history-control-position/findings.md b/.planning/history-control-position/findings.md new file mode 100644 index 0000000..36608d7 --- /dev/null +++ b/.planning/history-control-position/findings.md @@ -0,0 +1,9 @@ +# 发现记录 + +## 2026-09-13 + +- 用户截图显示“还有 16 条更早消息 / 查看更早消息”以胶囊形式停在消息内容上方,用户明确要求它回到“上面再显示”,不要悬着跟随。 +- `public/index.html` 已将控件放在 `.messages-wrap` 内、`#messages` 之前,但 `public/style.css` 给 `.history-load-more` 设置了 `position: absolute`、`top`、`left` 和水平位移,因此它覆盖消息且随可视区域悬浮。 +- `.messages-wrap` 当前没有纵向 flex 布局,`#messages` 仅使用 `height: 100%`;改为顶部普通节点 + 下方 `flex: 1` 滚动区即可保留现有分页、prepend 和滚动补偿逻辑。 +- 本次最小实现不需要改变历史消息协议或 `requestOlderHistory`;只调整布局样式并补充静态契约回归,避免引入新的滚动副作用。 +- `public/index.html` 原先固定使用旧的 `style.css` 查询版本;已更新为 `20260913-history-control-position`,避免浏览器缓存旧悬浮样式。 diff --git a/.planning/history-control-position/progress.md b/.planning/history-control-position/progress.md new file mode 100644 index 0000000..1f6b75e --- /dev/null +++ b/.planning/history-control-position/progress.md @@ -0,0 +1,16 @@ +# 进度日志 + +## 2026-09-13 + +- 建立本次历史消息入口位置修复计划,尚未开始代码修改。 +- 完成定位:`.history-load-more` 使用绝对定位覆盖消息;`.messages-wrap` 未为控件预留布局空间。 +- 确定最小修复方案:父容器纵向 flex,控件置于顶部正常流,消息区使用 `flex: 1` 独立滚动。 +- 新增 `history-recall` 位置契约,先按预期捕获当前 `position: absolute` 实现;进入 CSS 布局修复。 +- 确认 HTML 顺序已经是控件在 `#messages` 之前,无需改动历史协议或分页脚本;通过父容器布局让该顺序真正占据顶部空间。 +- CSS 已移除绝对定位、top/left/transform 和毛玻璃覆盖效果;`.messages-wrap` 改为纵向 flex,`.messages` 改为剩余空间滚动。 +- `node scripts/regression.js --target history-recall` 已通过。 +- 窄屏沿用 `max-width: min(92%, 560px)` 与内容收缩规则,不新增覆盖定位。 +- `node --check public/app.js`、`node --check scripts/regression.js`、`git diff --check` 均已通过。 +- 临时 `修复历史消息入口位置 TO DO list.csv` 已按流程清理;本次源码改动涉及 `public/index.html`、`public/style.css` 和 `scripts/regression.js`。 +- 同步更新 `public/index.html` 的样式查询版本,且历史回看回归新增缓存失效断言;新增验证仍全部通过。 +- 运行中 `127.0.0.1:8002` 已直接返回新 CSS 和新样式版本 URL;无需重启,刷新页面即可加载。 diff --git a/.planning/history-control-position/task_plan.md b/.planning/history-control-position/task_plan.md new file mode 100644 index 0000000..36f30b3 --- /dev/null +++ b/.planning/history-control-position/task_plan.md @@ -0,0 +1,43 @@ +# 修复历史消息入口位置 + +## 目标 + +将“还有 N 条更早消息 / 查看更早消息”入口放回消息流顶部的正常文档流中,避免它在滚动时悬浮、跟随内容或遮挡消息;同时保留连续翻页和失败重试能力。 + +## 阶段 + +| 阶段 | 状态 | 验收标准 | +|---|---|---| +| 1. 定位历史入口的 DOM、脚本和样式链路 | complete | 明确控件是否由 fixed/sticky 定位、插入到哪个滚动容器及滚动时的行为 | +| 2. 补充控件位置回归断言 | complete | 回归能锁定入口必须位于消息流顶部且不使用悬浮定位 | +| 3. 调整历史入口的 DOM 插入和滚动逻辑 | complete | 控件作为消息列表首部普通节点显示,加载后不跟随视口悬浮 | +| 4. 调整样式并兼容窄屏布局 | complete | 桌面和窄屏下入口均保持正常流布局,不遮挡消息内容 | +| 5. 运行定向回归、语法检查和差异检查 | complete | 相关回归、JS 语法检查、git diff --check 全部通过 | +| 6. 清理临时记录并交付修改结果 | complete | TODO CSV 删除,工作区仅保留本次修改并明确验证结果 | + +## 机器可读阶段状态 + +### Phase 1:定位历史入口的 DOM、脚本和样式链路 +**Status:** complete + +### Phase 2:补充控件位置回归断言 +**Status:** complete + +### Phase 3:调整历史入口的 DOM 插入和滚动逻辑 +**Status:** complete + +### Phase 4:调整样式并兼容窄屏布局 +**Status:** complete + +### Phase 5:运行定向回归、语法检查和差异检查 +**Status:** complete + +### Phase 6:清理临时记录并交付修改结果 +**Status:** complete + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---|---| +| 定向历史回归按预期失败:历史控件仍为绝对定位 | 1 | 已证明新增契约能捕获当前悬浮实现,进入布局修复 | +| `curl` 返回 23 | 1 | 仅因输出管道被 `rg -m 1` 提前关闭;同一响应已成功打印目标 CSS 片段,非服务错误 | 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 b990cb7..5644eff 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 50c3ebb..6bd0623 100644 --- a/public/app.js +++ b/public/app.js @@ -99,6 +99,8 @@ const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000; const SESSION_RESUME_FALLBACK_MS = 1_500; + const WS_CLIENT_HEARTBEAT_INTERVAL_MS = 15_000; + const WS_CLIENT_HEARTBEAT_TIMEOUT_MS = 10_000; function normalizeFrontendAssetVersion(value) { const version = String(value || '').trim().toLowerCase(); @@ -207,13 +209,21 @@ // --- State --- let ws = null; let wsAuthenticated = false; + // 只有服务端明确声明支持时才启用应用层心跳;旧版服务端会把它当成未知消息。 + let wsSupportsClientHeartbeat = false; + let wsHeartbeatTimer = null; + let wsHeartbeatPending = null; + let wsHeartbeatSeq = 0; let authToken = localStorage.getItem('cc-web-token'); let currentSessionId = null; let sessions = []; let sessionCache = new Map(); + // 未完整加载的历史只作为分页合并缓冲,绝不直接用于切换展示。 + let sessionHistoryBuffers = new Map(); let isGenerating = false; let reconnectAttempts = 0; let reconnectTimer = null; + let lastWsDisconnectNoticeAt = 0; let isPageUnloading = false; let pendingText = ''; let renderTimer = null; @@ -248,6 +258,19 @@ loading: false, error: '', }; + let outlineHistoryRequestSeq = 0; + let activeOutlineHistoryRequest = null; + let activeOutlineTargetRequest = null; + let currentOutlineHistoryState = { + sessionId: null, + cursor: 0, + total: 0, + available: false, + loading: false, + complete: false, + error: '', + entries: new Map(), + }; let sessionLoadOverlayTimer = null; let sessionLoadRequestTimer = null; let sessionResumeFallbackTimer = null; @@ -1916,6 +1939,79 @@ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } + function createOutlineHistoryState(sessionId = null) { + return { + sessionId, + cursor: 0, + total: 0, + available: false, + loading: false, + complete: false, + error: '', + entries: new Map(), + }; + } + + function resetOutlineHistoryState(sessionId = null) { + activeOutlineHistoryRequest = null; + activeOutlineTargetRequest = null; + currentOutlineHistoryState = createOutlineHistoryState(sessionId); + } + + function buildOutlineHistoryEntry(message, messageIndex) { + if (message?.role !== 'user') return null; + const normalizedIndex = Number(messageIndex); + if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0) return null; + const content = String(message.content || ''); + const messageId = String(message.id || message.messageId || '').trim(); + if (!messageId && !content.trim()) return null; + return { + id: messageId || `history-message-${normalizedIndex}`, + targetMessageId: '', + label: shortMessagePreview(content, 64), + timestamp: String(message.timestamp || message.createdAt || ''), + messageIndex: Math.trunc(normalizedIndex), + }; + } + + function mergeOutlineHistoryMessages(messages, baseIndex = 0) { + if (!Array.isArray(messages) || currentOutlineHistoryState.sessionId === null) return; + const normalizedBase = Number.isFinite(Number(baseIndex)) ? Number(baseIndex) : 0; + messages.forEach((message, index) => { + const entry = buildOutlineHistoryEntry(message, normalizedBase + index); + if (!entry) return; + const previous = currentOutlineHistoryState.entries.get(entry.messageIndex); + currentOutlineHistoryState.entries.set(entry.messageIndex, { ...previous, ...entry }); + }); + } + + function syncOutlineHistoryIndex(snapshot = {}) { + const sessionId = String(snapshot?.sessionId || snapshot?.id || '').trim(); + if (!sessionId) return; + if (currentOutlineHistoryState.sessionId !== sessionId) resetOutlineHistoryState(sessionId); + const baseIndex = Number.isFinite(Number(snapshot.historyBaseIndex)) + ? Number(snapshot.historyBaseIndex) + : Math.max(0, Number(snapshot.historyTotal || 0) - (snapshot.messages || []).length); + mergeOutlineHistoryMessages(snapshot.messages || [], baseIndex); + const incomingCursor = Number.isFinite(Number(snapshot.historyCursor)) + ? Math.max(0, Number(snapshot.historyCursor)) + : currentOutlineHistoryState.cursor; + const incomingTotal = Number.isFinite(Number(snapshot.historyTotal)) + ? Math.max(0, Number(snapshot.historyTotal)) + : currentOutlineHistoryState.total; + const complete = currentOutlineHistoryState.complete || incomingCursor === 0; + currentOutlineHistoryState = { + ...currentOutlineHistoryState, + cursor: complete ? 0 : incomingCursor, + total: Math.max(currentOutlineHistoryState.total, incomingTotal), + available: snapshot.historyAvailable !== undefined + ? snapshot.historyAvailable !== false + : currentOutlineHistoryState.available, + complete, + error: '', + }; + } + function buildUserOutlineTimelineItems(messageItems, titleHistory = []) { const timeline = []; const timelineMessages = []; @@ -1992,33 +2088,60 @@ } function buildUserOutlineItems() { - const seen = new Set(); - return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => { + const hasMessageIndex = (value) => value !== null && value !== '' && Number.isFinite(Number(value)); + const entries = new Map(); + currentOutlineHistoryState.entries.forEach((entry, messageIndex) => { + entries.set(`index:${messageIndex}`, { ...entry, messageIndex: Number(messageIndex) }); + }); + messagesDiv.querySelectorAll('.msg.user[data-message-id]').forEach((element) => { const id = String(element.dataset.messageId || '').trim(); - if (!id || seen.has(id)) return null; - seen.add(id); + if (!id) return; const indexed = userMessageIndex.get(id); const content = indexed?.content || element.querySelector('.msg-text')?.textContent || ''; const rawMessageIndex = String(element.dataset.messageIndex || '').trim(); const messageIndex = rawMessageIndex ? Number(rawMessageIndex) : Number.NaN; - return { + const entry = { id, targetMessageId: element.id || '', label: shortMessagePreview(content, 64), timestamp: indexed?.timestamp || '', messageIndex: Number.isFinite(messageIndex) ? messageIndex : null, }; - }).filter((entry) => entry && entry.targetMessageId); + const key = Number.isFinite(entry.messageIndex) ? `index:${entry.messageIndex}` : `id:${entry.id}`; + entries.set(key, { ...entries.get(key), ...entry }); + }); + return Array.from(entries.values()) + .filter((entry) => entry && (entry.id || hasMessageIndex(entry.messageIndex))) + .sort((left, right) => { + const leftIndex = Number(left.messageIndex); + const rightIndex = Number(right.messageIndex); + const hasLeftIndex = hasMessageIndex(left.messageIndex); + const hasRightIndex = hasMessageIndex(right.messageIndex); + if (hasLeftIndex && hasRightIndex && leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + if (hasLeftIndex !== hasRightIndex) return hasLeftIndex ? -1 : 1; + return 0; + }); } function updateUserOutlinePanel() { if (!userOutlinePanel || !userOutlineBtn) return; const items = buildUserOutlineTimelineItems(buildUserOutlineItems(), currentOutlineTitleHistory); + const historyState = currentOutlineHistoryState; + const historyStatus = historyState.loading + ? '
正在加载完整消息列表…
' + : historyState.error + ? '' + : historyState.cursor > 0 && !historyState.available + ? '
更早消息的原始记录不可用
' + : ''; if (items.length === 0) { - userOutlinePanel.innerHTML = '
暂无用户消息
'; - userOutlineBtn.disabled = true; + userOutlinePanel.innerHTML = historyStatus || '
暂无用户消息
'; + userOutlineBtn.disabled = !historyState.loading + && !(historyState.cursor > 0 && historyState.available); } else { - userOutlinePanel.innerHTML = items.map((item) => { + userOutlinePanel.innerHTML = `${items.map((item) => { if (item.type === 'date') { return ``; } @@ -2031,15 +2154,17 @@ `; } if (item.type === 'message') { + const messageIndex = Number(item.messageIndex); + const messageIndexAttr = Number.isFinite(messageIndex) ? ` data-message-index="${messageIndex}"` : ''; return ` - `; } return ''; - }).join(''); + }).join('')}${historyStatus}`; userOutlineBtn.disabled = false; } } @@ -2057,11 +2182,45 @@ closeCcwebPromptOutlinePanel(); userOutlinePanel.hidden = false; userOutlineBtn.setAttribute('aria-expanded', 'true'); + requestOutlineHistoryPage(); } else { closeUserOutlinePanel(); } } + function requestOutlineMessageTarget(messageIndex) { + const normalizedIndex = Number(messageIndex); + if (!currentSessionId || !Number.isFinite(normalizedIndex) || normalizedIndex < 0) return; + const target = messagesDiv.querySelector( + `[data-session-message="true"][data-message-index="${Math.trunc(normalizedIndex)}"]`, + ); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + if (activeOutlineTargetRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return; + pendingAdvancedSearchJump = { + sessionId: currentSessionId, + messageIndex: Math.trunc(normalizedIndex), + query: '', + }; + outlineHistoryRequestSeq += 1; + const requestId = `outline-target-${Date.now()}-${outlineHistoryRequestSeq}`; + activeOutlineTargetRequest = { + sessionId: currentSessionId, + requestId, + messageIndex: Math.trunc(normalizedIndex), + }; + closeUserOutlinePanel(); + send({ + type: 'load_history_page', + sessionId: currentSessionId, + before: Math.trunc(normalizedIndex) + 1, + requestId, + purpose: 'outline-target', + }); + } + function scrollToMessage(anchorId) { if (!anchorId) return; const target = document.getElementById(anchorId); @@ -2403,7 +2562,7 @@ return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0); } - function normalizeSessionSnapshot(payload, options = {}) { + function normalizeSessionSnapshot(payload) { const sessionId = payload.sessionId || payload.id || ''; const messages = cloneMessages(payload.messages || []); const historyTotal = Number.isFinite(Number(payload.historyTotal)) @@ -2412,6 +2571,16 @@ const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex)) ? Math.max(0, Number(payload.historyBaseIndex)) : Math.max(0, historyTotal - messages.length); + const historyBuffered = Number.isFinite(Number(payload.historyBuffered)) + ? Math.max(0, Number(payload.historyBuffered)) + : messages.length; + const historyCursor = Number.isFinite(Number(payload.historyCursor)) + ? Math.max(0, Number(payload.historyCursor)) + : Math.max(0, historyBaseIndex); + const historyTruncated = payload.historyTruncated === true + || historyCursor > 0 + || historyBaseIndex > 0; + const historyPending = !!payload.historyPending; return { sessionId, id: sessionId, @@ -2449,20 +2618,39 @@ || payload.taskTracking?.enabled === true, historyTotal, historyBaseIndex, - historyBuffered: Number.isFinite(Number(payload.historyBuffered)) - ? Math.max(0, Number(payload.historyBuffered)) - : messages.length, - historyCursor: Number.isFinite(Number(payload.historyCursor)) - ? Math.max(0, Number(payload.historyCursor)) - : Math.max(0, historyBaseIndex), - historyTruncated: !!payload.historyTruncated, + historyBuffered, + historyCursor, + historyTruncated, historyAvailable: payload.historyAvailable !== false, historySource: payload.historySource || 'snapshot', - historyPending: !!payload.historyPending, - complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, + historyPending, + complete: !historyPending + && !historyTruncated + && historyCursor === 0 + && historyBaseIndex === 0 + && historyBuffered >= historyTotal, }; } + function isCompleteSessionSnapshot(snapshot) { + if (!snapshot || snapshot.complete !== true || snapshot.historyPending || snapshot.historyTruncated) return false; + const historyTotal = Number.isFinite(Number(snapshot.historyTotal)) + ? Math.max(0, Number(snapshot.historyTotal)) + : Array.isArray(snapshot.messages) ? snapshot.messages.length : 0; + const historyBuffered = Number.isFinite(Number(snapshot.historyBuffered)) + ? Math.max(0, Number(snapshot.historyBuffered)) + : Array.isArray(snapshot.messages) ? snapshot.messages.length : 0; + const historyCursor = Number.isFinite(Number(snapshot.historyCursor)) + ? Math.max(0, Number(snapshot.historyCursor)) + : 0; + const historyBaseIndex = Number.isFinite(Number(snapshot.historyBaseIndex)) + ? Math.max(0, Number(snapshot.historyBaseIndex)) + : 0; + return historyCursor === 0 + && historyBaseIndex === 0 + && historyBuffered >= historyTotal; + } + function touchSessionCache(sessionId) { const entry = sessionCache.get(sessionId); if (entry) entry.lastUsed = Date.now(); @@ -2471,6 +2659,43 @@ function invalidateSessionCache(sessionId) { if (!sessionId) return; sessionCache.delete(sessionId); + sessionHistoryBuffers.delete(sessionId); + } + + function bufferSessionSnapshot(snapshot) { + if (!snapshot?.sessionId || isCompleteSessionSnapshot(snapshot)) { + if (snapshot?.sessionId) sessionHistoryBuffers.delete(snapshot.sessionId); + return; + } + const bufferedSnapshot = deepClone(snapshot); + const weight = estimateSessionSnapshotWeight(bufferedSnapshot); + if (weight > SESSION_CACHE_MAX_WEIGHT) { + sessionHistoryBuffers.delete(bufferedSnapshot.sessionId); + return; + } + sessionHistoryBuffers.set(bufferedSnapshot.sessionId, { + snapshot: bufferedSnapshot, + weight, + lastUsed: Date.now(), + }); + while (sessionHistoryBuffers.size > SESSION_CACHE_LIMIT) { + let oldestId = null; + let oldestTs = Infinity; + for (const [sessionId, entry] of sessionHistoryBuffers) { + if ((entry.lastUsed || 0) < oldestTs) { + oldestTs = entry.lastUsed || 0; + oldestId = sessionId; + } + } + if (!oldestId) break; + sessionHistoryBuffers.delete(oldestId); + } + } + + function getSessionHistoryBuffer(sessionId) { + const entry = sessionHistoryBuffers.get(sessionId); + if (entry) entry.lastUsed = Date.now(); + return entry?.snapshot || null; } function pruneSessionCache() { @@ -2492,7 +2717,7 @@ } function cacheSessionSnapshot(snapshot) { - if (!snapshot?.sessionId || !snapshot.complete) return; + if (!snapshot?.sessionId || !isCompleteSessionSnapshot(snapshot)) return; const cachedSnapshot = deepClone(snapshot); const weight = estimateSessionSnapshotWeight(cachedSnapshot); if (weight > SESSION_CACHE_MAX_WEIGHT) { @@ -2507,6 +2732,7 @@ weight, lastUsed: Date.now(), }); + sessionHistoryBuffers.delete(cachedSnapshot.sessionId); pruneSessionCache(); } @@ -2532,6 +2758,9 @@ const meta = getSessionMeta(sessionId); entry.meta = meta ? deepClone(meta) : null; } + for (const sessionId of sessionHistoryBuffers.keys()) { + if (!knownIds.has(sessionId)) sessionHistoryBuffers.delete(sessionId); + } } function mergeSessionListSnapshot(snapshot) { @@ -2588,7 +2817,10 @@ function getSessionCacheDisposition(sessionId) { const entry = sessionCache.get(sessionId); const meta = getSessionMeta(sessionId); - if (!entry?.snapshot?.complete || !meta) return 'miss'; + if (!entry?.snapshot || !isCompleteSessionSnapshot(entry.snapshot) || !meta) { + if (entry && !isCompleteSessionSnapshot(entry.snapshot)) sessionCache.delete(sessionId); + return 'miss'; + } if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) { return 'strong'; } @@ -6299,6 +6531,7 @@ closeFileBrowser(); currentSessionId = null; resetHistoryLoadState(null); + resetOutlineHistoryState(null); syncTaskTrackingControl(null); loadedHistorySessionId = null; currentSessionMessageCount = 0; @@ -6326,7 +6559,9 @@ updateReloadMcpButtonUI(); // 真正替换聊天 DOM 时,使之前 renderMessages 的异步批次失效。 renderEpoch++; - messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd); + messagesDiv.innerHTML = ''; + if (historyLoadMore) messagesDiv.appendChild(historyLoadMore); + messagesDiv.insertAdjacentHTML('beforeend', buildWelcomeMarkup(currentCwd)); setStatsDisplay(null); renderPendingAttachments(); renderPendingNotes({ scroll: false }); @@ -6358,9 +6593,13 @@ activeToolCalls.clear(); activeTodoCallTargets.clear(); } - if (currentSessionId !== snapshot.sessionId) resetHistoryLoadState(snapshot.sessionId); + if (currentSessionId !== snapshot.sessionId) { + resetHistoryLoadState(snapshot.sessionId); + resetOutlineHistoryState(snapshot.sessionId); + } currentSessionId = snapshot.sessionId; syncHistoryLoadControl(snapshot); + syncOutlineHistoryIndex(snapshot); syncTaskTrackingControl(snapshot); loadedHistorySessionId = snapshot.sessionId; currentOutlineTitleHistory = normalizeOutlineTitleHistory(snapshot.titleHistory); @@ -6651,27 +6890,96 @@ }); } + function requestOutlineHistoryPage() { + const state = currentOutlineHistoryState; + if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0 || !state.available) return; + if (activeOutlineHistoryRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return; + outlineHistoryRequestSeq += 1; + const requestId = `outline-history-${Date.now()}-${outlineHistoryRequestSeq}`; + activeOutlineHistoryRequest = { + sessionId: state.sessionId, + requestId, + before: state.cursor, + }; + currentOutlineHistoryState = { ...state, loading: true, error: '' }; + updateUserOutlinePanel(); + send({ + type: 'load_history_page', + sessionId: state.sessionId, + before: state.cursor, + requestId, + purpose: 'outline', + }); + } + function mergeHistoryChunkIntoCachedSnapshot(sessionId, messages, options = {}) { if (!sessionId || !Array.isArray(messages) || messages.length === 0) return; - updateCachedSession(sessionId, (snapshot) => { - const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : []; - const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex)) - ? Number(snapshot.historyBaseIndex) - : Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length); - const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; - const indexed = new Map(); - existingMessages.forEach((message, index) => indexed.set(existingBase + index, message)); - messages.forEach((message, index) => indexed.set(incomingBase + index, message)); - const indexes = Array.from(indexed.keys()).sort((a, b) => a - b); - snapshot.messages = indexes.map((index) => indexed.get(index)); - snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase; - snapshot.historyBuffered = snapshot.messages.length; - if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total)); - if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor)); - snapshot.historyTruncated = snapshot.historyCursor > 0; + const cachedSnapshot = sessionCache.get(sessionId)?.snapshot || null; + const bufferedSnapshot = getSessionHistoryBuffer(sessionId); + const snapshot = cachedSnapshot || bufferedSnapshot; + if (!snapshot) return; + const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : []; + const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex)) + ? Number(snapshot.historyBaseIndex) + : Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length); + const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; + const indexed = new Map(); + existingMessages.forEach((message, index) => indexed.set(existingBase + index, message)); + messages.forEach((message, index) => indexed.set(incomingBase + index, message)); + const indexes = Array.from(indexed.keys()).sort((a, b) => a - b); + snapshot.messages = indexes.map((index) => indexed.get(index)); + snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase; + snapshot.historyBuffered = snapshot.messages.length; + if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total)); + if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor)); + snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0; + snapshot.historyPending = false; + snapshot.complete = snapshot.historyCursor === 0 + && !snapshot.historyTruncated + && snapshot.historyBuffered >= snapshot.historyTotal; + if (isCompleteSessionSnapshot(snapshot)) { + cacheSessionSnapshot(snapshot); + } else { + if (cachedSnapshot) sessionCache.delete(sessionId); + bufferSessionSnapshot(snapshot); + } + } + + function mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, options = {}) { + if (!snapshot || !Array.isArray(messages) || messages.length === 0) return; + const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : []; + const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex)) + ? Number(snapshot.historyBaseIndex) + : Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length); + const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; + const indexed = new Map(); + existingMessages.forEach((message, index) => indexed.set(existingBase + index, message)); + messages.forEach((message, index) => indexed.set(incomingBase + index, message)); + const indexes = Array.from(indexed.keys()).sort((a, b) => a - b); + snapshot.messages = indexes.map((index) => indexed.get(index)); + snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase; + snapshot.historyBuffered = snapshot.messages.length; + if (Number.isFinite(Number(options.total))) { + snapshot.historyTotal = Math.max(0, Number(options.total)); + } + if (Number.isFinite(Number(options.cursor))) { + snapshot.historyCursor = Math.max(0, Number(options.cursor)); + } else if (options.lastChunk) { + snapshot.historyCursor = Math.max(0, snapshot.historyBaseIndex); + } + if (options.lastChunk) { snapshot.historyPending = false; - snapshot.complete = snapshot.historyCursor === 0; - }); + snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0; + if (!Number.isFinite(Number(snapshot.historyTotal))) { + snapshot.historyTotal = snapshot.messages.length; + } + snapshot.complete = snapshot.historyCursor === 0 + && !snapshot.historyTruncated + && snapshot.historyBuffered >= snapshot.historyTotal; + } else { + snapshot.historyPending = true; + snapshot.complete = false; + } } function createSessionSwitchRequestId(sessionId) { @@ -6716,7 +7024,6 @@ return; } if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) { - activeSessionLoad.snapshot.complete = true; cacheSessionSnapshot(activeSessionLoad.snapshot); } finishSessionSwitch(sessionId, requestId); @@ -7356,6 +7663,41 @@ reconnectTimer = null; } + function clearWsHeartbeat() { + if (wsHeartbeatTimer) { + clearInterval(wsHeartbeatTimer); + wsHeartbeatTimer = null; + } + wsHeartbeatPending = null; + } + + function sendWsHeartbeat(socket) { + if (!wsSupportsClientHeartbeat || ws !== socket || socket.readyState !== 1 || !wsAuthenticated) return; + const now = Date.now(); + if (wsHeartbeatPending) { + if (now - wsHeartbeatPending.sentAt <= WS_CLIENT_HEARTBEAT_TIMEOUT_MS) return; + wsHeartbeatPending = null; + try { socket.close(4000, 'heartbeat timeout'); } catch {} + return; + } + wsHeartbeatSeq += 1; + const requestId = `client-heartbeat-${Date.now()}-${wsHeartbeatSeq}`; + wsHeartbeatPending = { requestId, sentAt: now }; + try { + socket.send(JSON.stringify({ type: 'client_heartbeat', requestId })); + } catch { + wsHeartbeatPending = null; + try { socket.close(4000, 'heartbeat send failed'); } catch {} + } + } + + function startWsHeartbeat(socket) { + clearWsHeartbeat(); + if (!wsSupportsClientHeartbeat) return; + sendWsHeartbeat(socket); + wsHeartbeatTimer = setInterval(() => sendWsHeartbeat(socket), WS_CLIENT_HEARTBEAT_INTERVAL_MS); + } + function connect() { if (!canConnectWs()) return; if (ws && ws.readyState <= 1) return; @@ -7364,6 +7706,7 @@ const socket = new WebSocket(WS_URL); ws = socket; wsAuthenticated = false; + wsSupportsClientHeartbeat = false; socket.onopen = () => { if (ws !== socket) return; @@ -7390,6 +7733,27 @@ if (ws !== socket) return; ws = null; wsAuthenticated = false; + wsSupportsClientHeartbeat = false; + clearWsHeartbeat(); + if (activeOutlineHistoryRequest) { + activeOutlineHistoryRequest = null; + currentOutlineHistoryState = { + ...currentOutlineHistoryState, + loading: false, + error: '连接中断,连接恢复后可重试', + }; + if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel(); + } + if (activeOutlineTargetRequest) { + activeOutlineTargetRequest = null; + pendingAdvancedSearchJump = null; + if (!isPageUnloading) { + appendError('连接中断,定位消息加载失败;连接恢复后请重新点击该条目。', { + transient: true, + autoDismissMs: 7000, + }); + } + } if (activeHistoryPageRequest) { activeHistoryPageRequest = null; currentHistoryState = { @@ -7408,6 +7772,11 @@ recoverCurrent: activeSessionLoad.recoverCurrent === true, targetMessageIndex: activeSessionLoad.targetMessageIndex, }; + const now = Date.now(); + if (now - lastWsDisconnectNoticeAt >= 5000) { + lastWsDisconnectNoticeAt = now; + showToast('连接中断,正在重连并自动重试会话切换…'); + } } else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) { pendingSessionResumeRequest = { sessionId: currentSessionId, @@ -7490,6 +7859,12 @@ function handleServerMessage(msg) { if (handleTaskBoardProtocolMessage(msg)) return; switch (msg.type) { + case 'client_heartbeat_ack': + if (wsHeartbeatPending?.requestId === String(msg.requestId || '')) { + wsHeartbeatPending = null; + } + break; + case 'auth_result': if (msg.success) { if (shouldReloadForFrontendAssetVersion(msg.frontendAssetVersion)) { @@ -7499,6 +7874,8 @@ const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId; authToken = msg.token; wsAuthenticated = true; + wsSupportsClientHeartbeat = msg.features?.clientHeartbeat === true; + startWsHeartbeat(ws); localStorage.setItem('cc-web-token', msg.token); document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; @@ -7525,6 +7902,8 @@ pendingInitialSessionLoad = shouldLoadInitialSession; } } else { + clearWsHeartbeat(); + wsSupportsClientHeartbeat = false; pendingSessionSwitchRequest = null; pendingSessionResumeRequest = null; clearSessionResumeFallbackTimer(); @@ -7621,6 +8000,11 @@ break; } mergeSessionListSnapshot(snapshot); + if (isCompleteSessionSnapshot(snapshot)) { + sessionHistoryBuffers.delete(snapshot.sessionId); + } else { + bufferSessionSnapshot(snapshot); + } if (matchesActiveLoad) { activeSessionLoad.snapshot = snapshot; } @@ -7659,13 +8043,77 @@ const matchesManualHistoryLoad = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest && activeHistoryPageRequest.sessionId === msg.sessionId && activeHistoryPageRequest.requestId === historyRequestId); + const matchesOutlineHistoryLoad = !!(activeOutlineHistoryRequest + && activeOutlineHistoryRequest.sessionId === msg.sessionId + && activeOutlineHistoryRequest.requestId === historyRequestId); + const matchesOutlineTargetLoad = !!(activeOutlineTargetRequest + && activeOutlineTargetRequest.sessionId === msg.sessionId + && activeOutlineTargetRequest.requestId === historyRequestId); const allowsLegacyHistory = !historyRequestId && msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId; + if (matchesOutlineTargetLoad) { + if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) { + const baseIndex = Number.isFinite(Number(msg.historyBaseIndex)) + ? Number(msg.historyBaseIndex) + : 0; + prependHistoryMessages(msg.messages || [], { + preserveScroll: false, + baseIndex, + }); + mergeOutlineHistoryMessages(msg.messages || [], baseIndex); + mergeHistoryChunkIntoCachedSnapshot(msg.sessionId, msg.messages || [], { + baseIndex, + total: msg.historyTotal, + }); + activeOutlineTargetRequest = null; + scheduleAdvancedSearchJump(); + if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel(); + } else { + activeOutlineTargetRequest = null; + pendingAdvancedSearchJump = null; + } + break; + } + if (matchesOutlineHistoryLoad) { + if (msg.sessionId === currentSessionId && currentOutlineHistoryState.sessionId === msg.sessionId) { + const nextCursor = Number.isFinite(Number(msg.historyCursor)) + ? Math.max(0, Number(msg.historyCursor)) + : Math.max(0, activeOutlineHistoryRequest.before - (msg.messages || []).length); + mergeOutlineHistoryMessages( + msg.messages || [], + Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + ); + activeOutlineHistoryRequest = null; + currentOutlineHistoryState = { + ...currentOutlineHistoryState, + cursor: nextCursor, + total: Number.isFinite(Number(msg.historyTotal)) + ? Math.max(currentOutlineHistoryState.total, Number(msg.historyTotal)) + : currentOutlineHistoryState.total, + available: msg.historyAvailable !== false, + loading: false, + complete: nextCursor === 0, + error: '', + }; + if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel(); + if (nextCursor > 0) requestOutlineHistoryPage(); + } else { + activeOutlineHistoryRequest = null; + } + break; + } if (activeSessionLoad?.recoverCurrent && matchesActiveHistoryLoad) { if (activeSessionLoad.snapshot) { - activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []) - .concat(activeSessionLoad.snapshot.messages); + mergeHistoryChunkIntoSessionLoadSnapshot(activeSessionLoad.snapshot, msg.messages || [], { + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + cursor: msg.historyCursor, + total: msg.historyTotal, + lastChunk: !msg.remaining, + }); + if (!isCompleteSessionSnapshot(activeSessionLoad.snapshot)) { + bufferSessionSnapshot(activeSessionLoad.snapshot); + } } if (!msg.remaining) finalizeLoadedSession(msg.sessionId, historyRequestId || undefined); break; @@ -7675,13 +8123,25 @@ && loadedHistorySessionId === msg.sessionId) { const blocking = isBlockingSessionLoad(msg.sessionId); if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) { - activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages); + mergeHistoryChunkIntoSessionLoadSnapshot(activeSessionLoad.snapshot, msg.messages || [], { + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + cursor: msg.historyCursor, + total: msg.historyTotal, + lastChunk: !msg.remaining, + }); + if (!isCompleteSessionSnapshot(activeSessionLoad.snapshot)) { + bufferSessionSnapshot(activeSessionLoad.snapshot); + } } prependHistoryMessages(msg.messages || [], { preserveScroll: !blocking, skipScrollbar: blocking, baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, }); + mergeOutlineHistoryMessages( + msg.messages || [], + Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + ); scheduleAdvancedSearchJump(); if (matchesManualHistoryLoad) { const nextCursor = Number.isFinite(Number(msg.historyCursor)) @@ -8004,7 +8464,40 @@ break; case 'error': + // 兼容尚未重启的旧服务端:它会把新客户端心跳当成未知消息。 + // 该错误不代表业务请求失败,不能污染聊天区或触发切换失败提示。 + if (/^Unknown type:\s*client_heartbeat\s*$/i.test(String(msg.message || ''))) { + wsSupportsClientHeartbeat = false; + wsHeartbeatPending = null; + clearWsHeartbeat(); + break; + } const errorRequestId = String(msg.requestId || ''); + const matchesOutlineTargetError = !!(activeOutlineTargetRequest + && (!msg.sessionId || msg.sessionId === activeOutlineTargetRequest.sessionId) + && errorRequestId === activeOutlineTargetRequest.requestId); + if (matchesOutlineTargetError) { + activeOutlineTargetRequest = null; + pendingAdvancedSearchJump = null; + appendError(msg.message || '加载定位消息失败,请重试', { + transient: true, + autoDismissMs: 7000, + }); + break; + } + const matchesOutlineHistoryError = !!(activeOutlineHistoryRequest + && (!msg.sessionId || msg.sessionId === activeOutlineHistoryRequest.sessionId) + && errorRequestId === activeOutlineHistoryRequest.requestId); + if (matchesOutlineHistoryError) { + activeOutlineHistoryRequest = null; + currentOutlineHistoryState = { + ...currentOutlineHistoryState, + loading: false, + error: msg.message || '加载定位历史失败,请重试', + }; + if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel(); + break; + } const matchesHistoryPageError = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest && (!msg.sessionId || msg.sessionId === activeHistoryPageRequest.sessionId) && errorRequestId === activeHistoryPageRequest.requestId); @@ -10214,9 +10707,10 @@ collabAgentIdsByToolUseId = new Map(); closedCollabAgentIdsByToolUseId = new Map(); messagesDiv.innerHTML = ''; + if (historyLoadMore) messagesDiv.appendChild(historyLoadMore); clearUserMessageIndex(); if (messages.length === 0) { - messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd); + messagesDiv.insertAdjacentHTML('beforeend', buildWelcomeMarkup(currentCwd)); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); @@ -10265,7 +10759,7 @@ const prevScrollTop = messagesDiv.scrollTop; const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i)); - messagesDiv.insertBefore(frag, messagesDiv.firstChild); + messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null); updateUserOutlinePanel(); // Compensate scrollTop so visible area stays unchanged messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); @@ -10296,14 +10790,14 @@ const frag = document.createDocumentFragment(); uniqueMessages.forEach(({ message, index }) => frag.appendChild(buildMsgElement(message, baseIndex + index))); if (!preserveScroll) { - messagesDiv.insertBefore(frag, messagesDiv.firstChild); + messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null); updateUserOutlinePanel(); if (!skipScrollbar) updateScrollbar(); return; } const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; - messagesDiv.insertBefore(frag, messagesDiv.firstChild); + messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null); updateUserOutlinePanel(); messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); if (!skipScrollbar) updateScrollbar(); @@ -11315,8 +11809,14 @@ } function highlightActiveSession() { + let activeAssigned = false; document.querySelectorAll('.session-item').forEach((el) => { - el.classList.toggle('active', el.dataset.id === currentSessionId); + const isTarget = el.dataset.id === currentSessionId; + const isActive = isTarget && !activeAssigned; + if (isActive) activeAssigned = true; + el.classList.toggle('active', isActive); + if (isActive) el.setAttribute('aria-current', 'true'); + else el.removeAttribute('aria-current'); }); } @@ -12036,11 +12536,19 @@ toggleUserOutlinePanel(); }); userOutlinePanel.addEventListener('click', (e) => { + const retry = e.target instanceof HTMLElement ? e.target.closest('[data-outline-retry]') : null; + if (retry) { + e.preventDefault(); + requestOutlineHistoryPage(); + return; + } const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null; if (!target) return; const anchorId = target.getAttribute('data-target') || ''; + const messageIndex = Number(target.getAttribute('data-message-index')); closeUserOutlinePanel(); - scrollToMessage(anchorId); + if (anchorId) scrollToMessage(anchorId); + else if (Number.isFinite(messageIndex)) requestOutlineMessageTarget(messageIndex); }); } diff --git a/public/index.html b/public/index.html index ca771d0..34f9c72 100644 --- a/public/index.html +++ b/public/index.html @@ -23,7 +23,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -105,8 +105,8 @@
-
+