diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json deleted file mode 100644 index da973df..0000000 --- a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "runId": "86e8f20f-3e55-4ca6-9def-57b79725b4fc", - "sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9", - "scriptsDir": "/home/cc-web/.ccweb/scripts", - "name": "conversation-idle-listener-e2e-20260827.js", - "scriptPath": "/home/cc-web/.ccweb/scripts/conversation-idle-listener-e2e-20260827.js", - "status": "succeeded", - "startedAt": "2026-08-27T14:04:55.516Z", - "finishedAt": "2026-08-27T14:05:46.085Z", - "durationMs": 50569, - "exitCode": 0, - "signal": null, - "terminationReason": null, - "stopRequested": false, - "tokenRevoked": true, - "pid": 576697, - "stderrPreview": "" -} diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stderr.log b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stderr.log deleted file mode 100644 index e69de29..0000000 diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log deleted file mode 100644 index cf74cd8..0000000 --- a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log +++ /dev/null @@ -1 +0,0 @@ -{"sourceConversationId":"c01c2e84-5d52-4b0b-bab0-e10daf6c65c9","conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","initialStatus":"idle","statusDuringFirstTurn":"running","firstMessage":"未完成","continued":true,"continuationMessage":"任务完成","finalStatus":"idle","finalMessage":"任务完成","childIncluded":true,"idleEventCount":2,"events":[{"conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","event":"idle","previousStatus":"running","status":"idle","occurredAt":"2026-08-27T14:05:31.297Z","lastMessage":"未完成"},{"conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","event":"idle","previousStatus":"running","status":"idle","occurredAt":"2026-08-27T14:05:43.829Z","lastMessage":"任务完成"}]} diff --git a/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/run.json b/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/run.json deleted file mode 100644 index 7cf621a..0000000 --- a/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/run.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "runId": "c0f104c0-f80a-4890-86a5-96dd9a44fb78", - "sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9", - "scriptsDir": "/home/cc-web/.ccweb/scripts", - "name": "restart-smoke-20260826.js", - "scriptPath": "/home/cc-web/.ccweb/scripts/restart-smoke-20260826.js", - "status": "succeeded", - "startedAt": "2026-08-26T00:58:42.657Z", - "finishedAt": "2026-08-26T00:58:42.864Z", - "durationMs": 207, - "exitCode": 0, - "signal": null, - "terminationReason": null, - "stopRequested": false, - "tokenRevoked": true, - "pid": 1181595, - "stderrPreview": "" -} diff --git a/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/stderr.log b/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/stderr.log deleted file mode 100644 index e69de29..0000000 diff --git a/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/stdout.log b/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/stdout.log deleted file mode 100644 index fed92dc..0000000 --- a/.ccweb/scripts/.runs/c0f104c0-f80a-4890-86a5-96dd9a44fb78/stdout.log +++ /dev/null @@ -1 +0,0 @@ -{"smoke":true,"conversationId":"c01c2e84-5d52-4b0b-bab0-e10daf6c65c9"} diff --git a/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/run.json b/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/run.json deleted file mode 100644 index a20f6ac..0000000 --- a/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/run.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "runId": "caa16aa4-50ac-48fd-9b04-7596f1c4df99", - "sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9", - "scriptsDir": "/home/cc-web/.ccweb/scripts", - "name": "session-orchestration-e2e-20260826.js", - "scriptPath": "/home/cc-web/.ccweb/scripts/session-orchestration-e2e-20260826.js", - "status": "succeeded", - "startedAt": "2026-08-26T01:55:38.851Z", - "finishedAt": "2026-08-26T01:56:21.027Z", - "durationMs": 42176, - "exitCode": 0, - "signal": null, - "terminationReason": null, - "stopRequested": false, - "tokenRevoked": true, - "pid": 1239105, - "stderrPreview": "" -} diff --git a/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/stderr.log b/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/stderr.log deleted file mode 100644 index e69de29..0000000 diff --git a/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/stdout.log b/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/stdout.log deleted file mode 100644 index cb4cf4c..0000000 --- a/.ccweb/scripts/.runs/caa16aa4-50ac-48fd-9b04-7596f1c4df99/stdout.log +++ /dev/null @@ -1 +0,0 @@ -{"conversationId":"ca0da1e9-a3b4-46c1-a964-be1d1cb5db1e","branch":"测试成功","message":"已收到"} diff --git a/.planning/history-message-recall/findings.md b/.planning/history-message-recall/findings.md new file mode 100644 index 0000000..5d9d306 --- /dev/null +++ b/.planning/history-message-recall/findings.md @@ -0,0 +1,14 @@ +# 历史消息回看发现 + +## 初始状态 + +- 用户看到提示:“历史消息过多,cc-web 已只保留最近 45 条用于本地展示,省略 50 条旧消息。” +- 用户明确要求:旧消息可以默认隐藏,但必须有办法查看前面的内容。 + +## 代码证据 + +- `server.js:sanitizeMessagesForPersist` 生成用户看到的中文裁剪提示;它只影响持久化快照的展示内容。 +- `server.js:handleLoadSession` 已将历史拆成最近窗口和 `olderChunks`,并发送 `historyTotal/historyCursor/historyTruncated/historyPending` 元数据。 +- `server.js:handleLoadHistoryPage` 已存在按 `before` 和 `HISTORY_CHUNK_SIZE` 读取历史页的 WebSocket 入口,但当前固定返回 `remaining: 0`,需要确认前端是否能继续请求及服务端是否正确维护游标。 +- `public/app.js` 已存在 `prependHistoryMessages`,说明“前置插入旧消息”的渲染能力已有;还需要核对 `session_history_chunk` 处理、入口文案和重复/边界状态。 +- 计划审查指出验收必须明确:默认仍只渲染最近窗口、入口可操作、可连续查看旧消息、顺序和角色正确,并覆盖失败/重复点击/并发新消息等边界。 diff --git a/.planning/history-message-recall/progress.md b/.planning/history-message-recall/progress.md new file mode 100644 index 0000000..b699716 --- /dev/null +++ b/.planning/history-message-recall/progress.md @@ -0,0 +1,10 @@ +# 历史消息回看进度 + +## 日志 + +- 2026-09-11:建立隔离计划目录,准备定位历史消息裁剪和回看链路。 +- 2026-09-11:计划审查未通过,已补充验收标准、数量边界、分页/并发/权限风险;审查意见要求保留必要计划记录,只清理临时 TODO 文件。 +- 2026-09-11:代码图谱确认服务端已有 `handleLoadHistoryPage`、`handleLoadSession`/`splitHistoryMessages`,前端已有 `prependHistoryMessages`,问题更可能是入口或游标链路未闭合,而非完全没有历史能力。 +- 2026-09-11:实现服务端原始历史恢复、普通会话按需分页、历史游标回传和前端顶部回看控件;历史页按稳定消息索引去重并合并缓存。 +- 2026-09-11:新增 `history-recall` 回归断言,覆盖 45 条近期 + 50 条旧消息的切分、Codex rollout 解析、入口/分页协议和重复消息保护;该目标与完整 `npm run regression` 均通过。 +- 2026-09-11:按发布技能用本地 Bun baseline 重建 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`;语法检查、MCP initialize smoke 和归档清单均通过;临时 TODO 清单已清理。 diff --git a/.planning/history-message-recall/task_plan.md b/.planning/history-message-recall/task_plan.md new file mode 100644 index 0000000..b338e0c --- /dev/null +++ b/.planning/history-message-recall/task_plan.md @@ -0,0 +1,44 @@ +# 修复历史消息回看入口 + +## 目标 + +让被本地展示裁剪的旧消息仍然可通过明确入口回看,保留当前渲染性能与会话加载稳定性。 + +## 步骤 + +- [x] 定位消息裁剪逻辑、历史数据来源和现有接口 +- [x] 明确按需加载旧消息的交互与数据边界 +- [x] 实现历史消息回看入口及服务端/客户端衔接 +- [x] 补充裁剪与历史回看回归测试 +- [x] 运行测试并核验兼容性,完成后按约定清理临时 TODO 文件 + +## 验收标准 + +- 默认加载仍只渲染最近 45 条(或当前代码实际配置的同等窗口),不会因增加入口而自动拉取全部旧消息。 +- 出现“省略 50 条旧消息”时,界面有清晰可操作的回看入口,而不是只有数量提示。 +- 用户点击入口后能看到被省略消息的真实内容,并可继续按页查看更早消息。 +- 回看后的顺序、时间线、角色、内容和现有消息渲染保持正确;滚动位置不会跳乱。 +- 覆盖空历史、旧消息不足一页、连续多页、重复点击、加载失败和新消息并发到达等边界。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| + +## 当前决策 + +- 不删除旧消息数据,只调整本地展示和按需加载路径。 +- 先复用现有会话消息接口;只有确认接口无法分页时才扩展接口。 + +## 风险与约束 + +- 优先使用分页/游标加载,避免一次性把全部历史复制到浏览器内存。 +- 历史加载与实时新消息并发时,必须按稳定消息索引或 ID 去重并保持排序。 +- 历史接口沿用会话归属校验;失败时给出可理解的错误反馈并允许重试。 +- 不能破坏现有裁剪提示、缓存、滚动位置和消息渲染批处理。 + +## 已确认实现边界 + +- 旧消息优先从 Claude JSONL / Codex rollout 原始记录恢复;cc-web 快照只作为无原始记录时的降级来源。 +- 正常打开会话只渲染最近窗口;历史按钮使用现有 `load_history_page` WebSocket 按游标加载。 +- 搜索命中跳转仍允许目标预取,不受普通会话的按需加载策略影响。 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 7bdad05..c95448b 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 c411520..d54fb57 100644 --- a/public/app.js +++ b/public/app.js @@ -237,6 +237,17 @@ let codexConfigCache = null; let loadedHistorySessionId = null; let activeSessionLoad = null; + let historyPageRequestSeq = 0; + let activeHistoryPageRequest = null; + let currentHistoryState = { + sessionId: null, + cursor: 0, + remaining: 0, + total: 0, + available: false, + loading: false, + error: '', + }; let sessionLoadOverlayTimer = null; let sessionLoadRequestTimer = null; let sessionResumeFallbackTimer = null; @@ -436,6 +447,7 @@ const pendingNotesTray = $('#pending-notes-tray'); const imageUploadInput = $('#image-upload-input'); const attachBtn = $('#attach-btn'); + const historyLoadMore = $('#history-load-more'); const messagesDiv = $('#messages'); const msgInput = $('#msg-input'); const inputWrapper = msgInput.closest('.input-wrapper'); @@ -2437,6 +2449,15 @@ || 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, + historyAvailable: payload.historyAvailable !== false, + historySource: payload.historySource || 'snapshot', historyPending: !!payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, }; @@ -6277,6 +6298,7 @@ closeCcwebPromptOutlinePanel(); closeFileBrowser(); currentSessionId = null; + resetHistoryLoadState(null); syncTaskTrackingControl(null); loadedHistorySessionId = null; currentSessionMessageCount = 0; @@ -6336,7 +6358,9 @@ activeToolCalls.clear(); activeTodoCallTargets.clear(); } + if (currentSessionId !== snapshot.sessionId) resetHistoryLoadState(snapshot.sessionId); currentSessionId = snapshot.sessionId; + syncHistoryLoadControl(snapshot); syncTaskTrackingControl(snapshot); loadedHistorySessionId = snapshot.sessionId; currentOutlineTitleHistory = normalizeOutlineTitleHistory(snapshot.titleHistory); @@ -6539,6 +6563,117 @@ setSessionLoading(null, { blocking: false }); } + function resetHistoryLoadState(sessionId = null) { + activeHistoryPageRequest = null; + currentHistoryState = { + sessionId, + cursor: 0, + remaining: 0, + total: 0, + available: false, + loading: false, + error: '', + }; + renderHistoryLoadControl(); + } + + function renderHistoryLoadControl() { + if (!historyLoadMore) return; + const state = currentHistoryState; + historyLoadMore.innerHTML = ''; + historyLoadMore.hidden = !state.sessionId || state.cursor <= 0; + if (historyLoadMore.hidden) return; + + const copy = document.createElement('span'); + copy.className = 'history-load-more-copy'; + if (!state.available) { + copy.textContent = `还有 ${state.cursor} 条更早消息,但原始记录不可用,无法恢复。`; + historyLoadMore.classList.add('is-unavailable'); + historyLoadMore.appendChild(copy); + return; + } + historyLoadMore.classList.remove('is-unavailable'); + copy.textContent = state.error + ? state.error + : `还有 ${state.cursor} 条更早消息`; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'history-load-more-button'; + button.textContent = state.loading ? '正在加载…' : (state.error ? '重试' : '查看更早消息'); + button.disabled = state.loading; + button.addEventListener('click', requestOlderHistory); + historyLoadMore.append(copy, button); + } + + function syncHistoryLoadControl(payload = null) { + const sessionId = payload?.sessionId || currentSessionId; + if (!sessionId || sessionId !== currentSessionId) { + resetHistoryLoadState(sessionId || null); + return; + } + const nextCursor = Number.isFinite(Number(payload?.historyCursor)) + ? Math.max(0, Number(payload.historyCursor)) + : currentHistoryState.cursor; + currentHistoryState = { + ...currentHistoryState, + sessionId, + cursor: nextCursor, + remaining: nextCursor, + total: Number.isFinite(Number(payload?.historyTotal)) + ? Math.max(0, Number(payload.historyTotal)) + : currentHistoryState.total, + available: payload?.historyAvailable !== undefined + ? payload.historyAvailable !== false + : currentHistoryState.available, + error: '', + }; + renderHistoryLoadControl(); + } + + function requestOlderHistory() { + const state = currentHistoryState; + if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0 || !state.available) return; + if (activeHistoryPageRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return; + historyPageRequestSeq += 1; + const requestId = `history-page-${Date.now()}-${historyPageRequestSeq}`; + activeHistoryPageRequest = { + sessionId: state.sessionId, + requestId, + before: state.cursor, + }; + currentHistoryState = { ...state, loading: true, error: '' }; + renderHistoryLoadControl(); + send({ + type: 'load_history_page', + sessionId: state.sessionId, + before: state.cursor, + requestId, + }); + } + + 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; + snapshot.historyPending = false; + snapshot.complete = snapshot.historyCursor === 0; + }); + } + function createSessionSwitchRequestId(sessionId) { sessionSwitchRequestSeq += 1; return `session-load-${Date.now()}-${sessionSwitchRequestSeq}-${String(sessionId || '').slice(0, 8)}`; @@ -7255,6 +7390,15 @@ if (ws !== socket) return; ws = null; wsAuthenticated = false; + if (activeHistoryPageRequest) { + activeHistoryPageRequest = null; + currentHistoryState = { + ...currentHistoryState, + loading: false, + error: '连接中断,连接恢复后可重试', + }; + renderHistoryLoadControl(); + } if (activeSessionLoad?.sessionId && !isPageUnloading) { pendingSessionSwitchRequest = { sessionId: activeSessionLoad.sessionId, @@ -7512,6 +7656,9 @@ const matchesActiveHistoryLoad = !!(activeSessionLoad && activeSessionLoad.sessionId === msg.sessionId && (!historyRequestId || activeSessionLoad.requestId === historyRequestId)); + const matchesManualHistoryLoad = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest + && activeHistoryPageRequest.sessionId === msg.sessionId + && activeHistoryPageRequest.requestId === historyRequestId); const allowsLegacyHistory = !historyRequestId && msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId; @@ -7523,7 +7670,7 @@ if (!msg.remaining) finalizeLoadedSession(msg.sessionId, historyRequestId || undefined); break; } - if ((matchesActiveHistoryLoad || allowsLegacyHistory) + if ((matchesActiveHistoryLoad || matchesManualHistoryLoad || allowsLegacyHistory) && msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) { const blocking = isBlockingSessionLoad(msg.sessionId); @@ -7536,7 +7683,29 @@ baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, }); scheduleAdvancedSearchJump(); - if (!msg.remaining) { + if (matchesManualHistoryLoad) { + const nextCursor = Number.isFinite(Number(msg.historyCursor)) + ? Math.max(0, Number(msg.historyCursor)) + : Math.max(0, activeHistoryPageRequest.before - (msg.messages || []).length); + mergeHistoryChunkIntoCachedSnapshot(msg.sessionId, msg.messages || [], { + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, + cursor: nextCursor, + total: msg.historyTotal, + }); + activeHistoryPageRequest = null; + currentHistoryState = { + ...currentHistoryState, + cursor: nextCursor, + remaining: nextCursor, + total: Number.isFinite(Number(msg.historyTotal)) + ? Math.max(0, Number(msg.historyTotal)) + : currentHistoryState.total, + available: msg.historyAvailable !== false, + loading: false, + error: '', + }; + renderHistoryLoadControl(); + } else if (!msg.remaining) { finalizeLoadedSession(msg.sessionId, historyRequestId || undefined); } } @@ -7836,6 +8005,19 @@ case 'error': const errorRequestId = String(msg.requestId || ''); + const matchesHistoryPageError = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest + && (!msg.sessionId || msg.sessionId === activeHistoryPageRequest.sessionId) + && errorRequestId === activeHistoryPageRequest.requestId); + if (matchesHistoryPageError) { + activeHistoryPageRequest = null; + currentHistoryState = { + ...currentHistoryState, + loading: false, + error: msg.message || '加载更早消息失败,请重试', + }; + renderHistoryLoadControl(); + break; + } const matchesActiveLoadError = !!(activeSessionLoad && (!msg.sessionId || msg.sessionId === activeSessionLoad.sessionId) && (!errorRequestId || errorRequestId === activeSessionLoad.requestId)); @@ -10095,13 +10277,24 @@ function prependHistoryMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) return; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; - collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id)); + const existingIndexes = new Set(Array.from( + messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]'), + ).map((element) => Number(element.dataset.messageIndex))); + const uniqueMessages = []; + messages.forEach((message, index) => { + const messageIndex = baseIndex + index; + if (existingIndexes.has(messageIndex)) return; + existingIndexes.add(messageIndex); + uniqueMessages.push({ message, index }); + }); + if (uniqueMessages.length === 0) return; + collectClosedCollabAgentIds(uniqueMessages.map((entry) => entry.message)).forEach((id) => closedCollabAgentIds.add(id)); const preserveScroll = options.preserveScroll !== false; const skipScrollbar = options.skipScrollbar === true; const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const frag = document.createDocumentFragment(); - messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index))); + uniqueMessages.forEach(({ message, index }) => frag.appendChild(buildMsgElement(message, baseIndex + index))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); diff --git a/public/index.html b/public/index.html index e83def4..4de09de 100644 --- a/public/index.html +++ b/public/index.html @@ -105,6 +105,7 @@
+
diff --git a/public/style.css b/public/style.css index 2d46e20..21e6ee2 100644 --- a/public/style.css +++ b/public/style.css @@ -2727,6 +2727,57 @@ html[data-theme='wasteland'] .advanced-search-panel { overflow: hidden; min-height: 0; } +.history-load-more { + position: absolute; + z-index: 3; + top: 10px; + left: 50%; + display: inline-flex; + align-items: center; + gap: 10px; + max-width: min(92%, 560px); + padding: 7px 10px 7px 12px; + transform: translateX(-50%); + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border-color)); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-secondary) 92%, transparent); + box-shadow: 0 5px 18px rgba(35, 25, 18, 0.12); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; + backdrop-filter: blur(12px); +} +.history-load-more-copy { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.history-load-more-button { + flex: 0 0 auto; + min-height: 26px; + padding: 3px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border-color)); + border-radius: 999px; + background: var(--accent-light); + color: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} +.history-load-more-button:hover, +.history-load-more-button:focus-visible { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); +} +.history-load-more-button:disabled { + cursor: wait; + opacity: 0.62; +} +.history-load-more.is-unavailable { + border-color: color-mix(in srgb, var(--danger) 35%, var(--border-color)); + color: var(--danger); +} .messages { height: 100%; overflow-y: scroll; diff --git a/scripts/regression.js b/scripts/regression.js index 49be090..12bf889 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -4020,6 +4020,67 @@ function assertServerSessionHistoryRequestIdContract() { ); } +function assertHistoryRecallContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); + + assert(indexSource.includes('id="history-load-more"'), 'Chat view should expose a history load-more control'); + assert(frontendSource.includes('function requestOlderHistory'), 'Frontend should request older history on demand'); + assert(frontendSource.includes("type: 'load_history_page'"), 'Frontend should use the paged history WebSocket request'); + assert(frontendSource.includes('mergeHistoryChunkIntoCachedSnapshot'), 'Frontend should merge history pages into the session cache'); + assert( + /const existingIndexes = new Set\([\s\S]*?data-message-index/.test(frontendSource), + 'Frontend should deduplicate history messages by stable message index' + ); + assert(styleSource.includes('.history-load-more-button'), 'History load-more control should have dedicated styling'); + assert(serverSource.includes('function loadNativeSessionHistory'), 'Server should be able to recover native session history'); + assert(serverSource.includes('function resolveSessionHistory'), 'Server should resolve persisted and native history sources'); + assert( + /function handleLoadHistoryPage[\s\S]*?resolveSessionHistory\(session\)[\s\S]*?attachClientRequestId/.test(serverSource), + 'Paged history requests should use the resolved full history and echo requestId' + ); + assert( + /prefetchChunks: targetMessageIndex === null \? 0/.test(serverSource), + 'Normal session loads should keep older history on demand instead of prefetching it all' + ); + + const splitStart = serverSource.indexOf('function splitHistoryMessages'); + const splitEnd = serverSource.indexOf('const IS_WIN', splitStart); + assert(splitStart >= 0 && splitEnd > splitStart, 'Server should keep an extractable history splitter'); + const splitHistoryMessages = new Function( + 'INITIAL_HISTORY_COUNT', + 'HISTORY_CHUNK_SIZE', + 'HISTORY_PREFETCH_CHUNKS', + 'HISTORY_MAX_CHUNKS_PER_LOAD', + 'sanitizeMessagesForTransport', + `${serverSource.slice(splitStart, splitEnd)}; return splitHistoryMessages;`, + )(45, 24, 0, 8, (messages) => messages); + const fixture = Array.from({ length: 95 }, (_, index) => ({ id: `history-${index}` })); + const split = splitHistoryMessages(fixture, { prefetchChunks: 0 }); + assert(split.recentMessages.length === 45, 'History splitter should keep the configured recent window'); + assert(split.recentMessages[0]?.id === 'history-50', 'Recent history window should preserve chronological order'); + assert(split.historyRemaining === 50, 'History splitter should expose the exact older-message cursor'); + assert(split.olderChunks.length === 0, 'Normal history split should not prefetch older chunks'); + + const { createCodexRolloutStore } = require(path.join(REPO_DIR, 'lib', 'codex-rollouts.js')); + const store = createCodexRolloutStore({ + codexSessionsDir: '/tmp/cc-web-regression-no-rollouts', + sessionsDir: '/tmp/cc-web-regression-no-sessions', + normalizeSession: (value) => value, + sanitizeToolInput: (name, value) => value, + }); + const parsed = store.parseCodexRolloutLines([ + JSON.stringify({ type: 'session_meta', payload: { id: 'history-thread', cwd: '/tmp' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:00.000Z', payload: { type: 'user_message', message: '旧问题' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:01.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '旧回答' }] } }), + ]); + assert(parsed.messages.length === 2, 'Codex rollout parser should recover user and assistant history'); + assert(parsed.messages[0]?.content === '旧问题' && parsed.messages[1]?.content === '旧回答', + 'Recovered native history should preserve message order and content'); +} + function assertSessionSwitchRaceContract() { const checks = [ ['render epoch behavior', assertSessionRenderEpochRaceContract], @@ -6427,6 +6488,11 @@ async function main() { console.log('Session switch race regression checks passed.'); return; } + if (regressionTarget === 'history-recall') { + assertHistoryRecallContract(); + console.log('History recall regression checks passed.'); + return; + } if (regressionTarget === 'title-history-outline') { assertSetTitleMcpContract(); assertTitleHistoryOutlineContract(); @@ -6542,6 +6608,7 @@ async function main() { assertCcwebMcpChildUpdateCoalescingContract(); assertTitleHistoryOutlineContract(); assertSessionSwitchResilienceContract(); + assertHistoryRecallContract(); assertCodexAppGoalLifecycleContract(); assertSessionSwitchRaceContract(); assertMcpSessionVisibilityContract(); diff --git a/server.js b/server.js index de32ca1..ef5010f 100644 --- a/server.js +++ b/server.js @@ -4119,6 +4119,90 @@ function handleDirectoryPickerListApi(req, res, url) { const INITIAL_HISTORY_COUNT = 12; const HISTORY_CHUNK_SIZE = 24; +const SESSION_HISTORY_CACHE_LIMIT = 12; +const sessionHistoryCache = new Map(); + +function isPersistenceNoticeMessage(message) { + return !!(message && typeof message === 'object' && message.ccwebPersistenceNotice === true); +} + +function sessionHistoryCacheKey(session) { + if (!session) return ''; + if (isCodexLikeSession(session)) return `${getSessionAgent(session)}:${session.codexAppThreadId || session.codexThreadId || ''}`; + return `claude:${session.claudeSessionId || ''}`; +} + +function cacheSessionHistory(key, value) { + if (!key || !value?.filePath || !Array.isArray(value.messages)) return value; + sessionHistoryCache.delete(key); + sessionHistoryCache.set(key, value); + while (sessionHistoryCache.size > SESSION_HISTORY_CACHE_LIMIT) { + const oldestKey = sessionHistoryCache.keys().next().value; + if (!oldestKey) break; + sessionHistoryCache.delete(oldestKey); + } + return value; +} + +function loadNativeSessionHistory(session) { + const key = sessionHistoryCacheKey(session); + if (!key || (!isCodexLikeSession(session) && !session.claudeSessionId)) return null; + + let filePath = null; + if (isCodexLikeSession(session)) { + const threadId = session.codexAppThreadId || session.codexThreadId || ''; + if (!threadId) return null; + const candidates = getCodexRolloutFiles().filter((candidate) => ( + path.basename(candidate).includes(threadId) + )); + filePath = candidates[0] || null; + } else { + filePath = resolveClaudeSessionLocalMeta(session.claudeSessionId)?.filePath || null; + } + if (!filePath || !fs.existsSync(filePath)) return null; + + let stat; + try { + stat = fs.statSync(filePath); + } catch { + return null; + } + const cached = sessionHistoryCache.get(key); + if (cached && cached.filePath === filePath && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached; + } + + try { + const parsed = isCodexLikeSession(session) + ? parseCodexRolloutFile(filePath) + : { messages: parseJsonlToMessages(fs.readFileSync(filePath, 'utf8').split('\n')) }; + const messages = Array.isArray(parsed?.messages) ? parsed.messages : []; + if (messages.length === 0) return null; + return cacheSessionHistory(key, { filePath, mtimeMs: stat.mtimeMs, size: stat.size, messages }); + } catch (error) { + plog('WARN', 'session_native_history_load_failed', { + sessionId: String(session.id || '').slice(0, 8), + agent: getSessionAgent(session), + error: error?.message || String(error || ''), + }); + return null; + } +} + +function resolveSessionHistory(session) { + const persisted = Array.isArray(session?.messages) ? session.messages : []; + const persistedMessages = persisted.filter((message) => !isPersistenceNoticeMessage(message)); + const notice = persisted.some(isPersistenceNoticeMessage); + if (!notice) { + return { messages: persisted, source: 'snapshot', available: true, recoverable: false }; + } + + const native = loadNativeSessionHistory(session); + if (native?.messages?.length > persistedMessages.length) { + return { messages: native.messages, source: 'native', available: true, recoverable: true }; + } + return { messages: persisted, source: 'snapshot', available: false, recoverable: false }; +} function normalizeAgent(agent) { return VALID_AGENTS.has(agent) ? agent : 'claude'; @@ -4911,7 +4995,7 @@ function sanitizeMessagesForPersist(messages, limits = {}) { if (list.length > selected.length) { output.unshift({ role: 'system', - content: `历史消息过多,cc-web 已只保留最近 ${selected.length} 条用于本地展示,省略 ${list.length - selected.length} 条旧消息。`, + content: `历史消息过多,cc-web 本地快照只保留最近 ${selected.length} 条;点击顶部“查看更早消息”可从原始会话记录加载省略的 ${list.length - selected.length} 条旧消息。`, timestamp: new Date().toISOString(), ccwebPersistenceNotice: true, }); @@ -4996,7 +5080,7 @@ function buildSessionJsonForPersist(session) { ...session, messages: [{ role: 'system', - content: '当前会话历史过大,cc-web 已跳过本地历史明细写入,仅保留会话元数据以保护服务稳定性。', + content: `当前会话历史过大,cc-web 未将完整历史写入本地快照;如果原始会话记录仍在,可点击顶部“查看更早消息”尝试恢复。`, timestamp: new Date().toISOString(), ccwebPersistenceNotice: true, }], @@ -10589,14 +10673,17 @@ function handleLoadHistoryPage(ws, msg = {}) { message: 'Session not found', }, msg)); } - const list = sanitizeMessagesForTransport(session.messages); + const history = typeof resolveSessionHistory === 'function' + ? resolveSessionHistory(session) + : { messages: session.messages || [], source: 'snapshot', available: true }; + const list = sanitizeMessagesForTransport(history.messages); const requestedBefore = Number.parseInt(String(msg.before || ''), 10); const before = Number.isFinite(requestedBefore) ? Math.max(0, Math.min(list.length, requestedBefore)) : Math.max(0, list.length - INITIAL_HISTORY_COUNT); const end = before; const start = Math.max(0, end - HISTORY_CHUNK_SIZE); - wsSend(ws, { + wsSend(ws, attachClientRequestId({ type: 'session_history_chunk', sessionId: session.id, messages: list.slice(start, end), @@ -10604,7 +10691,10 @@ function handleLoadHistoryPage(ws, msg = {}) { historyCursor: start, historyBaseIndex: start, historyTruncated: start > 0, - }); + historyTotal: list.length, + historyAvailable: history.available, + historySource: history.source, + }, msg)); } function attachActiveRuntimeToWs(ws, sessionId, source = {}) { @@ -10708,16 +10798,19 @@ function handleLoadSession(ws, msg) { const requestedTargetMessageIndex = Number.parseInt(String( typeof msg === 'object' ? msg?.targetMessageIndex ?? '' : '', ), 10); + const history = typeof resolveSessionHistory === 'function' + ? resolveSessionHistory(refreshedSession) + : { messages: refreshedSession.messages || [], source: 'snapshot', available: true }; const targetMessageIndex = Number.isFinite(requestedTargetMessageIndex) - ? Math.max(0, Math.min(refreshedSession.messages.length - 1, requestedTargetMessageIndex)) + ? Math.max(0, Math.min(history.messages.length - 1, requestedTargetMessageIndex)) : null; - const recentHistoryBaseIndex = Math.max(0, refreshedSession.messages.length - INITIAL_HISTORY_COUNT); + const recentHistoryBaseIndex = Math.max(0, history.messages.length - INITIAL_HISTORY_COUNT); const targetPrefetchChunks = targetMessageIndex !== null && targetMessageIndex < recentHistoryBaseIndex ? Math.ceil((recentHistoryBaseIndex - targetMessageIndex) / HISTORY_CHUNK_SIZE) : 0; const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages( - refreshedSession.messages, - { prefetchChunks: Math.max(HISTORY_PREFETCH_CHUNKS, targetPrefetchChunks) }, + history.messages, + { prefetchChunks: targetMessageIndex === null ? 0 : Math.max(HISTORY_PREFETCH_CHUNKS, targetPrefetchChunks) }, ); const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null; const waitState = crossConversationWaitState(sessionId); @@ -10750,12 +10843,14 @@ function handleLoadSession(ws, msg) { cwd: effectiveCwd, totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, - historyTotal: refreshedSession.messages.length, + historyTotal: history.messages.length, historyBuffered, historyCursor: historyRemaining, - historyBaseIndex: Math.max(0, refreshedSession.messages.length - recentMessages.length), + historyBaseIndex: Math.max(0, history.messages.length - recentMessages.length), historyTruncated: historyRemaining > 0, historyPending: olderChunks.length > 0, + historyAvailable: history.available, + historySource: history.source, updated: refreshedSession.updated, isRunning: isSessionRunning(sessionId), waitingOnChildren: waitState.waitingOnChildren, @@ -10769,7 +10864,7 @@ function handleLoadSession(ws, msg) { }, msg)); if (olderChunks.length > 0) { - let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length); + let chunkEnd = Math.max(0, history.messages.length - recentMessages.length); olderChunks.forEach((chunk, index) => { const chunkStart = Math.max(0, chunkEnd - chunk.length); wsSend(ws, attachClientRequestId({ @@ -10780,6 +10875,9 @@ function handleLoadSession(ws, msg) { historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, historyBaseIndex: chunkStart, historyTruncated: historyRemaining > 0, + historyTotal: history.messages.length, + historyAvailable: history.available, + historySource: history.source, }, msg)); chunkEnd = chunkStart; });