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 @@