diff --git a/.planning/session-form-switch-race/findings.md b/.planning/session-form-switch-race/findings.md new file mode 100644 index 0000000..e72fa0f --- /dev/null +++ b/.planning/session-form-switch-race/findings.md @@ -0,0 +1,47 @@ +# 调研发现 + +## 用户侧证据 + +- 截图一:底部“表单”入口角标为 `1`,消息区只有普通助手消息,没有待处理表单卡片。 +- 截图二:执行 F5 后,同一会话顶部出现“先定空条件规则”表单,角标仍为 `1`。 +- 由此可排除“表单未持久化”这一主假设,更可能是 SPA 会话切换后的客户端状态同步或渲染失效。 + +## 待验证假设 + +1. 会话切换触发的消息请求存在竞态,旧会话响应晚到后覆盖当前消息列表。 +2. 待处理表单计数按全局/会话摘要更新,但表单卡片依赖另一份未同步的消息状态。 +3. 表单渲染使用了只在首次加载执行的派生缓存、去重集合或已处理 ID 集合,切换会话时未重置/重建。 + +## 代码定位(第一轮) + +- `renderPendingCcwebPrompts()` 通过 `collectCurrentPendingCcwebPrompts()` 统计表单角标。 +- `collectCurrentPendingCcwebPrompts()` 优先读取 `sessionCache.get(currentSessionId).snapshot.messages`,随后再扫描消息区 DOM;因此角标可以来自完整缓存,即使卡片尚未进入 DOM。 +- `renderMessages()` 对超过 10 条的历史消息采用分批渲染:先渲染最后 10 条,再用 `setTimeout` 逐批前插;每个延迟批次用全局 `renderEpoch` 防止旧会话继续写 DOM。 +- `renderMessages()` 首批完成后调用 `renderPendingNotes()`,后者会立即刷新表单角标;此时较旧的表单消息可能还未渲染,形成“角标为 1、卡片暂缺”的短暂状态。 +- `applySessionSnapshot()` 是缓存展示和服务端 `session_info` 的共同入口;缓存展示强制 `immediate: true`,正常服务端响应默认分批渲染。 +- 当前需要继续验证:快速切换过程中,哪个响应/早退分支使当前会话的剩余批次被 `renderEpoch` 永久取消,却没有再次对当前快照执行完整渲染。 + +## 已确认关键符号 + +- `public/app.js`: `openSession`、`showCachedSession`、`applySessionSnapshot`、`renderMessages` +- `public/app.js`: `collectCurrentPendingCcwebPrompts`、`renderPendingCcwebPrompts`、`renderPendingNotes` +- `scripts/regression.js`: `assertSessionSwitchResilienceContract` + +## 根因结论 + +1. `beginSessionSwitch()` 在请求意图阶段执行 `renderEpoch++`;如果新快照尚未提交、请求被快速切换覆盖或失败,旧 DOM 仍展示但其剩余批次已永久失效。真正替换 DOM 的 `renderMessages()` 本身已经推进并校验 `renderEpoch`,所以前者属于重复且有害的失效动作。 +2. `session_info` 的 `canSwitchToSessionInfo` 对当前 `sessionId` 无条件放行,没有阻止同一会话旧 `requestId` 响应重绘当前 DOM。 +3. `handleLoadSession()` 给 `session_info` 附带了客户端 `requestId`,但 `session_history_chunk` 没有;前端无法区分 `A→B→A` 中两次 A 加载的历史块。 +4. `finalizeLoadedSession()` 只按 `sessionId` 完成加载,旧 A 尾块可误完成新 A 请求。 + +## 测试策略 + +- 在 `scripts/regression.js` 新增无依赖、确定性的渲染 epoch 调度测试:发起 B 但不提交 B 时,A 的延迟表单批次必须继续;真正提交 B 后,A 批次必须停止。 +- 扩展会话切换静态契约:`beginSessionSwitch` 不推进 epoch;带请求 ID 的旧 `session_info` 不得仅因 sessionId 当前而放行;历史块及 finalize 路径校验请求 ID。 +- 扩展 WebSocket 集成断言:`session_info` 与同次 `session_history_chunk` 回传相同请求 ID。 + +## 仓库协作状态 + +- `.planning/.active_plan` 当前指向其他任务 `subagent-card-metadata`,本次不改动该指针。 +- `.trellis/.current-task` 当前指向其他任务 `07-17-gilded-wasteland-theme`,本次不覆盖。 +- 工作树已有无关未跟踪 CSV,必须保留。 diff --git a/.planning/session-form-switch-race/progress.md b/.planning/session-form-switch-race/progress.md new file mode 100644 index 0000000..5085689 --- /dev/null +++ b/.planning/session-form-switch-race/progress.md @@ -0,0 +1,25 @@ +# 进度日志 + +## 2026-07-18 + +- 已读取并启用 `planning-with-files` 与 `todo-list-csv` 技能。 +- 已分析用户提供的两张截图,确认 F5 可恢复表单渲染。 +- 已隔离创建本任务规划文件,未覆盖其他会话的活动计划。 +- 当前阶段:定位表单计数与消息渲染的会话切换链路。 +- 计划审查代理已通过计划,无阻塞问题。 +- `codebase-memory-mcp` 索引 `home-cc-web` 状态为 ready(3950 节点、8664 边)。 +- 已确认表单角标读取完整 `sessionCache`,消息区历史采用带 `renderEpoch` 的异步分批 DOM 渲染,二者存在可观察状态分叉。 +- 两条独立只读调查均确认 `beginSessionSwitch` 过早失效渲染批次;另确认同一会话旧 `requestId` 响应与无请求 ID 历史块存在竞态。 +- 已完成步骤 1,进入步骤 2:先编写可确定性失败的回归测试。 +- 已在 `scripts/regression.js` 新增 `session-switch-race` 定向回归入口,并接入默认回归。 +- 已运行 `node scripts/regression.js --target session-switch-race`,当前生产代码如预期失败:渲染 epoch、旧 session_info、历史块/finalize 请求归属、服务端历史块 requestId 五类断言全部命中。 +- 已完成步骤 2、3,进入步骤 4:修改 `public/app.js` 与 `server.js`。 +- 注意:`scripts/regression.js` 同时出现其他会话新增的侧栏测试改动,本任务不覆盖、不回退。 +- 已修改 `public/app.js`:渲染 epoch 只在真实 DOM 替换时推进;`session_info`、历史块与 finalize 按加载 requestId 拒绝旧代次。 +- 已修改 `server.js`:`session_history_chunk` 透传 `load_session` 的 requestId。 +- 已完成步骤 4;定向 `session-switch-race` 回归、三份 JS 语法检查及 diff whitespace 检查均通过,进入步骤 5 完整回归。 +- 第一次完整回归失败于旧源码字符串契约 `!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession`;行为兼容分支仍存在,已改为具名布尔量以恢复旧契约,待重跑。 +- 定向 `session-switch-race` 回归再次通过。 +- 完整 `npm run regression` 通过并输出 `Regression checks passed.`,退出码 0。 +- 已完成步骤 5,进入步骤 6:静态检查与影响面审查。 +- `codebase-memory-mcp detect_changes` 在影响分析阶段返回 `Transport closed`;已按降级策略改用本地静态检查与独立只读审查。 diff --git a/.planning/session-form-switch-race/task_plan.md b/.planning/session-form-switch-race/task_plan.md new file mode 100644 index 0000000..ccbf915 --- /dev/null +++ b/.planning/session-form-switch-race/task_plan.md @@ -0,0 +1,48 @@ +# 会话切换后表单不渲染修复计划 + +## 目标 + +修复频繁切换会话后“表单”角标已有待处理数量、但消息区未渲染表单的问题,确保切回会话后无需 F5 即可看到并操作待处理表单。 + +## 验收标准 + +- 频繁切换包含待处理表单和普通消息的多个会话后,当前会话的表单立即渲染。 +- 旧会话异步请求晚到时,不得覆盖当前会话的消息或表单派生状态。 +- 表单角标数量与消息区实际可见待处理表单保持一致。 +- 页面刷新后的恢复行为与 SPA 内切换行为一致。 +- 新增竞态回归测试,并通过相关前端测试与静态检查。 + +## 工作步骤 + +1. [DONE] 定位表单计数与消息渲染的会话切换链路 +2. [DONE] 编写可复现竞态的回归测试 +3. [DONE] 运行回归测试并确认当前实现失败 +4. [DONE] 修复会话切换时的表单状态同步与过期请求覆盖 +5. [DONE] 运行前端相关测试并确认回归通过 +6. [IN_PROGRESS] 执行静态检查并审查影响面 +7. [TODO] 使用真实浏览器验证频繁切换无需刷新 + +## 关键假设 + +- 后端数据完整,因为 F5 后同一待处理表单能够显示。 +- 缺陷位于前端会话切换、消息加载或表单派生渲染状态的同步路径。 +- 修复应优先采用请求归属校验或会话级状态隔离,不依赖强制刷新。 + +## 已确认根因与修复边界 + +- `beginSessionSwitch()` 在切换请求刚发起、DOM 尚未替换时提前执行 `renderEpoch++`,会永久取消当前会话尚未完成的历史消息批次;表单若位于该批次,便形成“缓存角标有 1、DOM 卡片为 0”。 +- `session_info` 对 `msg.sessionId === currentSessionId` 无条件放行,同一会话旧 `requestId` 响应可能覆盖新加载。 +- 服务端 `session_history_chunk` 未回传 `requestId`,前端历史块也只按 `sessionId` 判断;`A→B→A` 时旧 A 尾块可能误完成新 A 加载。 +- 修复同时覆盖:渲染代次只在真实 DOM 提交时推进;加载响应按 `sessionId + requestId` 归属;历史分块透传请求 ID。 + +## 风险 + +- 消息加载、流式事件和表单计数可能来自不同异步通道,需要避免只修复其中一条路径。 +- 仓库由多个会话共享,不能覆盖现有 `.planning/.active_plan`、Trellis 当前任务或无关工作树改动。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 完整回归的旧静态断言要求保留 requestless idle 分支原字符串 | 1 | 将兼容分支拆为具名布尔量,保留语义和可审查契约后重跑 | +| `codebase-memory-mcp detect_changes` 返回 `Transport closed` | 1 | 不重复同一失败调用,降级为本地 git diff/rg/语法检查和独立审查代理 | 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 8e92629..dce5ff0 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 e265531..1fee40b 100644 --- a/public/app.js +++ b/public/app.js @@ -4275,6 +4275,8 @@ updateSessionIdBadge(); updateCwdBadge(); updateReloadMcpButtonUI(); + // 真正替换聊天 DOM 时,使之前 renderMessages 的异步批次失效。 + renderEpoch++; messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd); setStatsDisplay(null); renderPendingAttachments(); @@ -4513,21 +4515,41 @@ (!sessionId || activeSessionLoad.sessionId === sessionId)); } - function finishSessionSwitch(sessionId) { + function finishSessionSwitch(sessionId, requestId) { + // 带 requestId 的完成事件必须属于当前加载代次,避免旧请求尾块关闭新请求。 + if (requestId && (!activeSessionLoad || + activeSessionLoad.sessionId !== sessionId || + activeSessionLoad.requestId !== requestId)) { + return; + } if (isBlockingSessionLoad(sessionId)) { scrollToBottom(); - requestAnimationFrame(() => clearSessionLoading(sessionId)); + requestAnimationFrame(() => { + // RAF 执行时再次校验请求代次,避免旧请求清除同会话的新加载。 + if (requestId && (!activeSessionLoad || + activeSessionLoad.sessionId !== sessionId || + activeSessionLoad.requestId !== requestId)) { + return; + } + clearSessionLoading(sessionId); + }); return; } clearSessionLoading(sessionId); } - function finalizeLoadedSession(sessionId) { + function finalizeLoadedSession(sessionId, requestId) { + // 带 requestId 的完成事件必须属于当前加载代次,避免旧 A 尾块完成新 A 请求。 + if (requestId && (!activeSessionLoad || + activeSessionLoad.sessionId !== sessionId || + activeSessionLoad.requestId !== requestId)) { + return; + } if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) { activeSessionLoad.snapshot.complete = true; cacheSessionSnapshot(activeSessionLoad.snapshot); } - finishSessionSwitch(sessionId); + finishSessionSwitch(sessionId, requestId); } function beginSessionSwitch(sessionId, options = {}) { @@ -4536,7 +4558,6 @@ const force = options.force === true; if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return; if (!force && sessionId === currentSessionId && !activeSessionLoad) return; - renderEpoch++; loadedHistorySessionId = null; setSessionLoading(sessionId, { blocking, label: options.label }); requestSessionLoad(sessionId, { blocking, label: options.label }); @@ -5304,24 +5325,28 @@ && (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd) && (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode)); const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId - && (!activeLoad.requestId || activeLoad.requestId === messageRequestId)); + && (!messageRequestId || activeLoad.requestId === messageRequestId)); const matchesPendingNewSession = !!(pendingNewSession - && ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId) + && ((messageRequestId && pendingNewSession.requestId === messageRequestId) || matchesPendingNewSessionFallback)); + const allowsRequestlessCurrentSession = !messageRequestId && msg.sessionId === currentSessionId; + const allowsRequestlessIdleSession = !messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession; + const allowsRequestlessUnclaimedSession = !messageRequestId && !activeLoad && !pendingNewSession; const canSwitchToSessionInfo = matchesActiveLoad || matchesPendingNewSession - || msg.sessionId === currentSessionId - || (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession) - || (!messageRequestId && !activeLoad && !pendingNewSession); + // 带 requestId 的响应只能由对应的活动加载/新建请求接管视图; + // 没有 requestId 的旧服务事件保留当前会话兼容路径。 + || allowsRequestlessCurrentSession + || allowsRequestlessIdleSession + || allowsRequestlessUnclaimedSession; + if (!canSwitchToSessionInfo) { + // 旧代次事件不能污染缓存或会话列表。 + break; + } mergeSessionListSnapshot(snapshot); if (matchesActiveLoad) { activeSessionLoad.snapshot = snapshot; } - if (!canSwitchToSessionInfo) { - if (!msg.historyPending) cacheSessionSnapshot(snapshot); - renderSessionList(); - break; - } if (matchesPendingNewSession) { pendingNewSessionRequest = null; if (!matchesActiveLoad) clearSessionLoading(); @@ -5341,34 +5366,48 @@ } if (!msg.historyPending) { if (matchesActiveLoad) { - finalizeLoadedSession(msg.sessionId); + finalizeLoadedSession(msg.sessionId, messageRequestId || undefined); } else { cacheSessionSnapshot(snapshot); - finishSessionSwitch(msg.sessionId); + finishSessionSwitch(msg.sessionId, messageRequestId || undefined); } } break; - case 'session_history_chunk': - if (activeSessionLoad?.recoverCurrent && activeSessionLoad.sessionId === msg.sessionId) { - if (!msg.remaining) finalizeLoadedSession(msg.sessionId); + case 'session_history_chunk': { + const historyRequestId = String(msg.requestId || ''); + const matchesActiveHistoryLoad = !!(activeSessionLoad + && activeSessionLoad.sessionId === msg.sessionId + && (!historyRequestId || activeSessionLoad.requestId === historyRequestId)); + const allowsLegacyHistory = !historyRequestId + && msg.sessionId === currentSessionId + && loadedHistorySessionId === msg.sessionId; + if (activeSessionLoad?.recoverCurrent && matchesActiveHistoryLoad) { + if (activeSessionLoad.snapshot) { + activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []) + .concat(activeSessionLoad.snapshot.messages); + } + if (!msg.remaining) finalizeLoadedSession(msg.sessionId, historyRequestId || undefined); + break; + } + if ((matchesActiveHistoryLoad || allowsLegacyHistory) + && msg.sessionId === currentSessionId + && 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); + } + prependHistoryMessages(msg.messages || [], { + preserveScroll: !blocking, + skipScrollbar: blocking, + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + }); + if (!msg.remaining) { + finalizeLoadedSession(msg.sessionId, historyRequestId || undefined); + } + } break; } - if (msg.sessionId === currentSessionId && 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); - } - prependHistoryMessages(msg.messages || [], { - preserveScroll: !blocking, - skipScrollbar: blocking, - baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, - }); - if (!msg.remaining) { - finalizeLoadedSession(msg.sessionId); - } - } - break; case 'session_message': if (msg.sessionId && msg.message) { diff --git a/public/index.html b/public/index.html index f84c4ae..0bf7052 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/public/style.css b/public/style.css index bae0c21..be11a2a 100644 --- a/public/style.css +++ b/public/style.css @@ -3970,7 +3970,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { font-size: 12px; } .user-outline-panel { - right: 0; + left: 0; + right: auto; width: min(320px, calc(100vw - 20px)); } } @@ -4039,12 +4040,16 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { right: auto; } .user-outline-panel { - left: 0; + left: auto; right: 0; - width: auto; + width: min(320px, calc(100vw - 20px)); max-height: min(44vh, 300px); bottom: calc(100% + 6px); } + .ccweb-prompt-outline-panel { + left: 0; + right: auto; + } .msg-copy-btn { opacity: 1; width: 24px; @@ -9030,6 +9035,13 @@ html[data-theme='gilded'] { box-shadow: var(--sidebar-hover-shadow); } + /* 收起后控制条会贴近左侧轨道,定位浮层需朝聊天画布展开,避免越过应用边界被裁切。 */ + .app.sidebar-collapsed .user-outline-panel { + left: 0; + right: auto; + z-index: 120; + } + .app.sidebar-collapsed .sidebar::after { content: ''; position: absolute; diff --git a/scripts/regression.js b/scripts/regression.js index 3c07b01..062b158 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -538,6 +538,18 @@ function assertFrontendSidebarCollapseContract() { assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint'); assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities'); assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root'); + assert( + indexSource.includes('id="user-outline-panel" class="user-outline-panel"') + && indexSource.includes('id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel"'), + 'User-message and pending-form locators should share the responsive outline panel layout' + ); + const baseOutlinePanelStyleStart = styleSource.indexOf('.user-outline-panel {'); + const baseOutlinePanelStyleEnd = styleSource.indexOf('.user-outline-empty', baseOutlinePanelStyleStart); + const baseOutlinePanelStyle = styleSource.slice(baseOutlinePanelStyleStart, baseOutlinePanelStyleEnd); + assert( + /\.user-outline-panel\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0;/.test(baseOutlinePanelStyle), + 'Fixed-expanded desktop locators should retain their right-aligned base placement' + ); assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference'); assert( source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))") @@ -555,10 +567,13 @@ function assertFrontendSidebarCollapseContract() { ); assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail'); + const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)'); + const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart); assert( - styleSource.includes('@media (width > 768px) and (hover: hover) and (pointer: fine)'), + desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart, 'Desktop hover behavior should stay isolated from touch and narrow-screen drawers' ); + const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd); assert( styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))') && styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'), @@ -569,6 +584,28 @@ function assertFrontendSidebarCollapseContract() { && styleSource.includes('transform: translateX(0)'), 'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus' ); + assert( + /\.app\.sidebar-collapsed \.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*z-index:\s*120;/.test(desktopCollapseStyle), + 'Both collapsed-sidebar locator panels should open toward the chat canvas above the hover preview' + ); + const mobileSidebarStyleStart = styleSource.indexOf('@media (max-width: 768px)'); + const mobileSidebarStyleEnd = styleSource.indexOf('@media (max-width: 480px)', mobileSidebarStyleStart); + const mobileSidebarStyle = styleSource.slice(mobileSidebarStyleStart, mobileSidebarStyleEnd); + assert( + /\.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(mobileSidebarStyle), + 'Closed mobile drawer should keep both locator panels inside the chat canvas' + ); + const compactMobileStyleStart = mobileSidebarStyleEnd; + const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart); + const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd); + assert( + /\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle), + 'Compact-mobile user locator should open left from the second grid column' + ); + assert( + /\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle), + 'Compact-mobile pending-form locator should open right from the first grid column' + ); assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment'); assert( source.includes('if (isSidebarDesktopCollapseMode())') @@ -581,8 +618,8 @@ function assertFrontendSidebarCollapseContract() { 'Rich themes should provide isolated rail treatments on top of the shared semantic fallback' ); assert( - indexSource.includes('style.css?v=20260718-wasteland-bg-right-4') - && indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), + indexSource.includes('style.css?v=20260718-shared-locator-panel-2') + && indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Sidebar interaction assets should share the reviewed cache-busting version' ); } @@ -874,8 +911,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=20260718-wasteland-bg-right-4'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); - assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Theme bundle app script should use the current cache-busted asset URL'); + assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); + assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Theme bundle app script should use the current cache-busted asset URL'); } function assertFrontendWastelandThemeContract() { @@ -1128,8 +1165,8 @@ function assertFrontendWastelandThemeContract() { assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`); }); - assert(indexSource.includes('style.css?v=20260718-wasteland-bg-right-4'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); - assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Wasteland registration should share the cache-busted theme bundle URL'); + assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); + assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Wasteland registration should share the cache-busted theme bundle URL'); } function assertFrontendCcwebPromptContract() { @@ -2130,6 +2167,472 @@ function assertSessionSwitchResilienceContract() { ); } +function assertSessionRenderEpochRaceContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const beginSessionSwitchSource = extractFunctionSource(frontendSource, 'beginSessionSwitch'); + const renderMessagesSource = extractFunctionSource(frontendSource, 'renderMessages'); + const api = new Function(` + let renderEpoch = 0; + let loadedHistorySessionId = 'session-a'; + let activeSessionLoad = null; + let currentSessionId = 'session-a'; + let closedCollabAgentIds = new Set(); + let collabAgentStateCache = new Map(); + let collabAgentIdsByToolUseId = new Map(); + let closedCollabAgentIdsByToolUseId = new Map(); + const scheduled = []; + const messagesDiv = { + nodes: [], + scrollTop: 0, + get scrollHeight() { return this.nodes.length; }, + get firstChild() { return this.nodes[0] || null; }, + get innerHTML() { return ''; }, + set innerHTML(value) { this.nodes = []; }, + appendChild(node) { + this.nodes.push(...(node && node.__fragment ? node.nodes : [node])); + }, + insertBefore(node) { + this.nodes.unshift(...(node && node.__fragment ? node.nodes : [node])); + }, + }; + const document = { + createDocumentFragment() { + return { + __fragment: true, + nodes: [], + appendChild(node) { this.nodes.push(node); }, + }; + }, + }; + const currentCwd = '/tmp/session-render-race'; + function setTimeout(callback) { scheduled.push(callback); return scheduled.length; } + function setSessionLoading(sessionId) { + activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null; + } + function requestSessionLoad() {} + function collectClosedCollabAgentIds() { return new Set(); } + function clearUserMessageIndex() {} + function buildWelcomeMarkup() { return '

welcome

'; } + function updateUserOutlinePanel() {} + function renderPendingNotes() {} + function scrollToBottom() {} + function updateScrollbar() {} + function buildMsgElement(message) { return { id: message.id }; } + ${beginSessionSwitchSource} + ${renderMessagesSource} + return { + beginSessionSwitch, + renderMessages, + flushTimers() { + while (scheduled.length > 0) scheduled.shift()(); + }, + renderedIds: () => messagesDiv.nodes.map((node) => node.id), + }; + `)(); + + const sessionAMessages = Array.from({ length: 11 }, (_, index) => ({ id: `a-${index}` })); + api.renderMessages(sessionAMessages); + assert( + api.renderedIds().length === 10 && !api.renderedIds().includes('a-0'), + 'Render epoch race fixture should leave the oldest A message in the delayed batch' + ); + api.beginSessionSwitch('session-b', { force: true, blocking: false }); + api.flushTimers(); + assert( + JSON.stringify(api.renderedIds()) === JSON.stringify(sessionAMessages.map((message) => message.id)), + 'Beginning a B load must not cancel A delayed batches before the B snapshot is committed' + ); + + api.renderMessages(sessionAMessages); + api.beginSessionSwitch('session-b', { force: true, blocking: false }); + api.renderMessages([{ id: 'b-0' }]); + api.flushTimers(); + assert( + JSON.stringify(api.renderedIds()) === JSON.stringify(['b-0']), + 'Committing the B render must invalidate A delayed batches so stale messages cannot leak into B' + ); + assert( + !/\brenderEpoch\s*(?:\+\+|--|[+\-*/%]?=)/.test(beginSessionSwitchSource), + 'beginSessionSwitch should not advance renderEpoch before the replacement snapshot renders' + ); +} + +function assertSessionRequestIdRaceContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const sessionInfoStart = frontendSource.indexOf("case 'session_info':"); + const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart); + const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart); + assert( + sessionInfoStart >= 0 && sessionInfoDecisionStart > sessionInfoStart && sessionInfoDecisionEnd > sessionInfoDecisionStart, + 'Frontend should keep an extractable request gate before applying session_info' + ); + const sessionInfoDecisionSource = frontendSource.slice(sessionInfoDecisionStart, sessionInfoDecisionEnd); + const decideSessionInfo = new Function('msg', 'snapshot', 'activeLoad', 'pendingNewSession', 'currentSessionId', ` + ${sessionInfoDecisionSource} + return { canSwitchToSessionInfo, matchesActiveLoad }; + `); + const staleCurrentDecision = decideSessionInfo( + { + type: 'session_info', + sessionId: 'session-a', + requestId: 'request-old-a', + messages: [{ id: 'stale-a' }], + historyPending: false, + isRunning: false, + }, + { + sessionId: 'session-a', + agent: 'codexapp', + messages: [{ id: 'stale-a' }], + }, + { + sessionId: 'session-b', + requestId: 'request-new-b', + snapshot: null, + }, + null, + 'session-a' + ); + const rejectsStaleCurrentSessionInfo = staleCurrentDecision.canSwitchToSessionInfo === false; + + const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession'); + const finalizeApi = new Function(` + let activeSessionLoad = { + sessionId: 'session-b', + requestId: 'request-new-b', + snapshot: { messages: [{ id: 'b-0' }] }, + }; + const cached = []; + const finished = []; + function cacheSessionSnapshot(snapshot) { cached.push(snapshot); } + function finishSessionSwitch(sessionId) { finished.push(sessionId); } + ${finalizeLoadedSessionSource} + return { + finalizeLoadedSession, + cached, + finished, + reset() { cached.length = 0; finished.length = 0; }, + }; + `)(); + finalizeApi.finalizeLoadedSession('session-b', 'request-old-b'); + const rejectsStaleFinalize = finalizeApi.cached.length === 0 && finalizeApi.finished.length === 0; + finalizeApi.reset(); + finalizeApi.finalizeLoadedSession('session-b', 'request-new-b'); + const acceptsMatchingFinalize = finalizeApi.cached.length === 1 && finalizeApi.finished.length === 1; + + const historyStart = frontendSource.indexOf("case 'session_history_chunk':"); + const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart); + assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable session_history_chunk handler'); + const historyCaseSource = frontendSource.slice(historyStart, historyEnd); + const historyApi = new Function(` + let activeSessionLoad = { + sessionId: 'session-b', + requestId: 'request-new-b', + recoverCurrent: false, + snapshot: { messages: [{ id: 'b-recent' }] }, + }; + let currentSessionId = 'session-b'; + let loadedHistorySessionId = 'session-b'; + const prepended = []; + const finalized = []; + function cloneMessages(messages) { return messages.slice(); } + function isBlockingSessionLoad() { return false; } + function prependHistoryMessages(messages) { prepended.push(...messages); } + function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); } + function handleHistoryMessage(msg) { + switch (msg.type) { + ${historyCaseSource} + } + } + return { + handleHistoryMessage, + prepended, + finalized, + reset() { prepended.length = 0; finalized.length = 0; }, + }; + `)(); + historyApi.handleHistoryMessage({ + type: 'session_history_chunk', + sessionId: 'session-b', + requestId: 'request-old-b', + messages: [{ id: 'stale-history' }], + remaining: 0, + historyBaseIndex: 0, + }); + const rejectsStaleHistoryChunk = historyApi.prepended.length === 0 && historyApi.finalized.length === 0; + historyApi.reset(); + historyApi.handleHistoryMessage({ + type: 'session_history_chunk', + sessionId: 'session-b', + requestId: 'request-new-b', + messages: [{ id: 'matching-history' }], + remaining: 0, + historyBaseIndex: 0, + }); + const acceptsMatchingHistoryChunk = historyApi.prepended.some((message) => message.id === 'matching-history') + && historyApi.finalized.some((entry) => entry.requestId === 'request-new-b'); + + const failures = []; + if (!rejectsStaleCurrentSessionInfo) { + failures.push('A stale requestId-bearing session_info for the current A view must not overwrite the pending B load'); + } + if (!rejectsStaleFinalize) { + failures.push('finalizeLoadedSession must reject a stale requestId even when the sessionId still matches'); + } + if (!acceptsMatchingFinalize) { + failures.push('finalizeLoadedSession should still commit the matching active load'); + } + if (!rejectsStaleHistoryChunk) { + failures.push('session_history_chunk must reject stale requestIds before prepending or finalizing history'); + } + if (!acceptsMatchingHistoryChunk) { + failures.push('session_history_chunk should preserve the matching active request path'); + } + assert(failures.length === 0, failures.join('; ')); +} + +function assertBlockingFinishRafRequestRaceContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad'); + const clearSessionLoadingSource = extractFunctionSource(frontendSource, 'clearSessionLoading'); + const finishSessionSwitchSource = extractFunctionSource(frontendSource, 'finishSessionSwitch'); + const api = new Function(` + let activeSessionLoad = { + sessionId: 'session-a', + requestId: 'request-a1', + blocking: true, + }; + const rafCallbacks = []; + let scrollCount = 0; + function setSessionLoading(sessionId) { + activeSessionLoad = sessionId ? { sessionId, requestId: 'unexpected', blocking: true } : null; + } + function scrollToBottom() { scrollCount += 1; } + function requestAnimationFrame(callback) { rafCallbacks.push(callback); return rafCallbacks.length; } + ${isBlockingSessionLoadSource} + ${clearSessionLoadingSource} + ${finishSessionSwitchSource} + return { + finishA1() { finishSessionSwitch('session-a', 'request-a1'); }, + replaceWithA2() { + activeSessionLoad = { + sessionId: 'session-a', + requestId: 'request-a2', + blocking: true, + }; + }, + flushRaf() { + while (rafCallbacks.length > 0) rafCallbacks.shift()(); + }, + activeRequestId: () => activeSessionLoad?.requestId || null, + queuedRafCount: () => rafCallbacks.length, + scrollCount: () => scrollCount, + }; + `)(); + + api.finishA1(); + assert( + api.queuedRafCount() === 1 && api.scrollCount() === 1 && api.activeRequestId() === 'request-a1', + 'Blocking A1 completion should defer clearing through the real finishSessionSwitch RAF path' + ); + api.replaceWithA2(); + api.flushRaf(); + assert( + api.activeRequestId() === 'request-a2', + 'The deferred A1 RAF callback must not clear a newer A2 load for the same sessionId' + ); +} + +function assertRecoverCurrentHistoryMergeContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession'); + const historyStart = frontendSource.indexOf("case 'session_history_chunk':"); + const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart); + assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable recovery history handler'); + const historyCaseSource = frontendSource.slice(historyStart, historyEnd); + const api = new Function(` + let activeSessionLoad = { + sessionId: 'session-recover', + requestId: 'request-recover-new', + recoverCurrent: true, + blocking: false, + snapshot: { messages: [{ id: 'recent' }] }, + }; + let currentSessionId = 'session-recover'; + let loadedHistorySessionId = 'session-recover'; + const prepended = []; + const cached = []; + const finished = []; + function cloneMessages(messages) { return messages.map((message) => ({ ...message })); } + function isBlockingSessionLoad() { return false; } + function prependHistoryMessages(messages) { prepended.push(...messages); } + function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); } + function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); } + ${finalizeLoadedSessionSource} + function handleHistoryMessage(msg) { + switch (msg.type) { + ${historyCaseSource} + } + } + return { + handleHistoryMessage, + snapshotMessageIds: () => activeSessionLoad.snapshot.messages.map((message) => message.id), + prepended, + cached, + finished, + }; + `)(); + + api.handleHistoryMessage({ + type: 'session_history_chunk', + sessionId: 'session-recover', + requestId: 'request-recover-stale', + messages: [{ id: 'stale-history' }], + remaining: 0, + historyBaseIndex: 0, + }); + const rejectsStaleChunk = JSON.stringify(api.snapshotMessageIds()) === JSON.stringify(['recent']) + && api.prepended.length === 0 + && api.cached.length === 0 + && api.finished.length === 0; + + api.handleHistoryMessage({ + type: 'session_history_chunk', + sessionId: 'session-recover', + requestId: 'request-recover-new', + messages: [{ id: 'history-later' }], + remaining: 1, + historyBaseIndex: 1, + }); + const mergesIntermediateChunk = JSON.stringify(api.snapshotMessageIds()) + === JSON.stringify(['history-later', 'recent']); + const keepsRecoveryOffDom = api.prepended.length === 0; + const defersRecoveryFinalize = api.cached.length === 0 && api.finished.length === 0; + + api.handleHistoryMessage({ + type: 'session_history_chunk', + sessionId: 'session-recover', + requestId: 'request-recover-new', + messages: [{ id: 'history-oldest' }], + remaining: 0, + historyBaseIndex: 0, + }); + const cachedSnapshot = api.cached[0] || null; + const cachesCompleteHistory = api.cached.length === 1 + && cachedSnapshot.complete === true + && JSON.stringify((cachedSnapshot.messages || []).map((message) => message.id)) + === JSON.stringify(['history-oldest', 'history-later', 'recent']); + const finalizesMatchingRecovery = api.finished.length === 1 + && api.finished[0].requestId === 'request-recover-new'; + const neverPrependsRecoveryHistory = api.prepended.length === 0; + + const failures = []; + if (!rejectsStaleChunk) failures.push('recoverCurrent must still reject stale requestId history chunks'); + if (!mergesIntermediateChunk) failures.push('recoverCurrent must merge each accepted history chunk into the active snapshot'); + if (!keepsRecoveryOffDom || !neverPrependsRecoveryHistory) failures.push('recoverCurrent history chunks must not prepend the live DOM'); + if (!defersRecoveryFinalize) failures.push('recoverCurrent must wait for the last history chunk before caching'); + if (!cachesCompleteHistory) failures.push('recoverCurrent finalization must cache recent and historical messages as one complete snapshot'); + if (!finalizesMatchingRecovery) failures.push('recoverCurrent finalization must retain the matching requestId'); + assert(failures.length === 0, failures.join('; ')); +} + +function assertServerSessionHistoryRequestIdContract() { + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession'); + const api = new Function(` + const sent = []; + const fixture = { + id: 'session-b', + title: 'Session B', + pinnedAt: null, + permissionMode: 'yolo', + model: 'gpt-5.5', + agent: 'codexapp', + hasUnread: false, + cwd: '/tmp/session-b', + totalCost: 0, + totalUsage: null, + updated: '2026-07-18T00:00:00.000Z', + messages: Array.from({ length: 11 }, (_, index) => ({ id: 'b-' + index })), + }; + const activeProcesses = new Map(); + const activeCodexAppTurns = new Map(); + const wsSessionMap = new Map(); + function sanitizeId(value) { return String(value || ''); } + function reconcilePendingCrossConversationReplies() {} + function loadSession() { return fixture; } + function wsSend(ws, message) { sent.push(message); } + function attachClientRequestId(message, source) { + return source && source.requestId ? { ...message, requestId: source.requestId } : message; + } + function flushPendingCrossConversationReplies() {} + function getSessionAgent(session) { return session.agent; } + function splitHistoryMessages(messages) { + return { + recentMessages: messages.slice(1), + olderChunks: [messages.slice(0, 1)], + historyRemaining: 0, + historyBuffered: messages.length, + }; + } + function crossConversationWaitState() { + return { + waitingOnChildren: false, + pendingReplyCount: 0, + readyReplyCount: 0, + waitingReplyCount: 0, + failedReplyCount: 0, + pendingReplies: [], + }; + } + function detachWsFromActiveRuntimes() {} + function saveSession() {} + function publicTitleMetadata() { return {}; } + function sessionModelLabel(session) { return session.model; } + function isSessionRunning() { return false; } + function attachActiveRuntimeToWs() {} + function resolveClaudeSessionLocalMeta() { return null; } + ${handleLoadSessionSource} + return { + load(requestId) { + sent.length = 0; + handleLoadSession({}, { type: 'load_session', sessionId: fixture.id, requestId }); + return sent.slice(); + }, + }; + `)(); + const messages = api.load('request-new-b'); + const sessionInfo = messages.find((message) => message.type === 'session_info'); + const historyChunks = messages.filter((message) => message.type === 'session_history_chunk'); + assert(sessionInfo?.requestId === 'request-new-b', 'Server session_info fixture should echo the load requestId'); + assert(historyChunks.length > 0, 'Server load fixture should emit at least one delayed history chunk'); + assert( + historyChunks.every((message) => message.requestId === sessionInfo.requestId), + 'Every server history chunk must echo the exact requestId carried by its session_info snapshot' + ); +} + +function assertSessionSwitchRaceContract() { + const checks = [ + ['render epoch behavior', assertSessionRenderEpochRaceContract], + ['frontend requestId behavior', assertSessionRequestIdRaceContract], + ['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract], + ['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract], + ['server history requestId behavior', assertServerSessionHistoryRequestIdContract], + ]; + const failures = []; + for (const [label, check] of checks) { + try { + check(); + } catch (err) { + failures.push(`${label}: ${err?.message || err}`); + } + } + if (failures.length > 0) { + throw new Error(`Session switch race regression failed:\n- ${failures.join('\n- ')}`); + } +} + function assertCodexAppStaleRunningRecoveryContract() { const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete'); @@ -2820,6 +3323,11 @@ async function main() { console.log('Codex App stale running regression checks passed.'); return; } + if (regressionTarget === 'session-switch-race') { + assertSessionSwitchRaceContract(); + console.log('Session switch race regression checks passed.'); + return; + } throw new Error(`Unknown regression target: ${regressionTarget}`); } @@ -2839,6 +3347,7 @@ async function main() { assertFrontendPrimaryCodexAppUiContract(); assertSetTitleMcpContract(); assertSessionSwitchResilienceContract(); + assertSessionSwitchRaceContract(); assertCodexAppChildToolFallbackContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); diff --git a/server.js b/server.js index b2aaefd..28e701e 100644 --- a/server.js +++ b/server.js @@ -7710,7 +7710,7 @@ function handleLoadSession(ws, msg) { let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length); olderChunks.forEach((chunk, index) => { const chunkStart = Math.max(0, chunkEnd - chunk.length); - wsSend(ws, { + wsSend(ws, attachClientRequestId({ type: 'session_history_chunk', sessionId: refreshedSession.id, messages: chunk, @@ -7718,7 +7718,7 @@ function handleLoadSession(ws, msg) { historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, historyBaseIndex: chunkStart, historyTruncated: historyRemaining > 0, - }); + }, msg)); chunkEnd = chunkStart; }); }