修复会话切换兼容并重新打包发布

This commit is contained in:
shiyue
2026-09-13 23:59:27 +08:00
parent d9db66ca8f
commit e785e3c5dd
15 changed files with 1224 additions and 70 deletions

View File

@@ -99,6 +99,8 @@
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
const SESSION_RESUME_FALLBACK_MS = 1_500;
const WS_CLIENT_HEARTBEAT_INTERVAL_MS = 15_000;
const WS_CLIENT_HEARTBEAT_TIMEOUT_MS = 10_000;
function normalizeFrontendAssetVersion(value) {
const version = String(value || '').trim().toLowerCase();
@@ -207,13 +209,21 @@
// --- State ---
let ws = null;
let wsAuthenticated = false;
// 只有服务端明确声明支持时才启用应用层心跳;旧版服务端会把它当成未知消息。
let wsSupportsClientHeartbeat = false;
let wsHeartbeatTimer = null;
let wsHeartbeatPending = null;
let wsHeartbeatSeq = 0;
let authToken = localStorage.getItem('cc-web-token');
let currentSessionId = null;
let sessions = [];
let sessionCache = new Map();
// 未完整加载的历史只作为分页合并缓冲,绝不直接用于切换展示。
let sessionHistoryBuffers = new Map();
let isGenerating = false;
let reconnectAttempts = 0;
let reconnectTimer = null;
let lastWsDisconnectNoticeAt = 0;
let isPageUnloading = false;
let pendingText = '';
let renderTimer = null;
@@ -248,6 +258,19 @@
loading: false,
error: '',
};
let outlineHistoryRequestSeq = 0;
let activeOutlineHistoryRequest = null;
let activeOutlineTargetRequest = null;
let currentOutlineHistoryState = {
sessionId: null,
cursor: 0,
total: 0,
available: false,
loading: false,
complete: false,
error: '',
entries: new Map(),
};
let sessionLoadOverlayTimer = null;
let sessionLoadRequestTimer = null;
let sessionResumeFallbackTimer = null;
@@ -1916,6 +1939,79 @@
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function createOutlineHistoryState(sessionId = null) {
return {
sessionId,
cursor: 0,
total: 0,
available: false,
loading: false,
complete: false,
error: '',
entries: new Map(),
};
}
function resetOutlineHistoryState(sessionId = null) {
activeOutlineHistoryRequest = null;
activeOutlineTargetRequest = null;
currentOutlineHistoryState = createOutlineHistoryState(sessionId);
}
function buildOutlineHistoryEntry(message, messageIndex) {
if (message?.role !== 'user') return null;
const normalizedIndex = Number(messageIndex);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0) return null;
const content = String(message.content || '');
const messageId = String(message.id || message.messageId || '').trim();
if (!messageId && !content.trim()) return null;
return {
id: messageId || `history-message-${normalizedIndex}`,
targetMessageId: '',
label: shortMessagePreview(content, 64),
timestamp: String(message.timestamp || message.createdAt || ''),
messageIndex: Math.trunc(normalizedIndex),
};
}
function mergeOutlineHistoryMessages(messages, baseIndex = 0) {
if (!Array.isArray(messages) || currentOutlineHistoryState.sessionId === null) return;
const normalizedBase = Number.isFinite(Number(baseIndex)) ? Number(baseIndex) : 0;
messages.forEach((message, index) => {
const entry = buildOutlineHistoryEntry(message, normalizedBase + index);
if (!entry) return;
const previous = currentOutlineHistoryState.entries.get(entry.messageIndex);
currentOutlineHistoryState.entries.set(entry.messageIndex, { ...previous, ...entry });
});
}
function syncOutlineHistoryIndex(snapshot = {}) {
const sessionId = String(snapshot?.sessionId || snapshot?.id || '').trim();
if (!sessionId) return;
if (currentOutlineHistoryState.sessionId !== sessionId) resetOutlineHistoryState(sessionId);
const baseIndex = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || 0) - (snapshot.messages || []).length);
mergeOutlineHistoryMessages(snapshot.messages || [], baseIndex);
const incomingCursor = Number.isFinite(Number(snapshot.historyCursor))
? Math.max(0, Number(snapshot.historyCursor))
: currentOutlineHistoryState.cursor;
const incomingTotal = Number.isFinite(Number(snapshot.historyTotal))
? Math.max(0, Number(snapshot.historyTotal))
: currentOutlineHistoryState.total;
const complete = currentOutlineHistoryState.complete || incomingCursor === 0;
currentOutlineHistoryState = {
...currentOutlineHistoryState,
cursor: complete ? 0 : incomingCursor,
total: Math.max(currentOutlineHistoryState.total, incomingTotal),
available: snapshot.historyAvailable !== undefined
? snapshot.historyAvailable !== false
: currentOutlineHistoryState.available,
complete,
error: '',
};
}
function buildUserOutlineTimelineItems(messageItems, titleHistory = []) {
const timeline = [];
const timelineMessages = [];
@@ -1992,33 +2088,60 @@
}
function buildUserOutlineItems() {
const seen = new Set();
return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => {
const hasMessageIndex = (value) => value !== null && value !== '' && Number.isFinite(Number(value));
const entries = new Map();
currentOutlineHistoryState.entries.forEach((entry, messageIndex) => {
entries.set(`index:${messageIndex}`, { ...entry, messageIndex: Number(messageIndex) });
});
messagesDiv.querySelectorAll('.msg.user[data-message-id]').forEach((element) => {
const id = String(element.dataset.messageId || '').trim();
if (!id || seen.has(id)) return null;
seen.add(id);
if (!id) return;
const indexed = userMessageIndex.get(id);
const content = indexed?.content || element.querySelector('.msg-text')?.textContent || '';
const rawMessageIndex = String(element.dataset.messageIndex || '').trim();
const messageIndex = rawMessageIndex ? Number(rawMessageIndex) : Number.NaN;
return {
const entry = {
id,
targetMessageId: element.id || '',
label: shortMessagePreview(content, 64),
timestamp: indexed?.timestamp || '',
messageIndex: Number.isFinite(messageIndex) ? messageIndex : null,
};
}).filter((entry) => entry && entry.targetMessageId);
const key = Number.isFinite(entry.messageIndex) ? `index:${entry.messageIndex}` : `id:${entry.id}`;
entries.set(key, { ...entries.get(key), ...entry });
});
return Array.from(entries.values())
.filter((entry) => entry && (entry.id || hasMessageIndex(entry.messageIndex)))
.sort((left, right) => {
const leftIndex = Number(left.messageIndex);
const rightIndex = Number(right.messageIndex);
const hasLeftIndex = hasMessageIndex(left.messageIndex);
const hasRightIndex = hasMessageIndex(right.messageIndex);
if (hasLeftIndex && hasRightIndex && leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
if (hasLeftIndex !== hasRightIndex) return hasLeftIndex ? -1 : 1;
return 0;
});
}
function updateUserOutlinePanel() {
if (!userOutlinePanel || !userOutlineBtn) return;
const items = buildUserOutlineTimelineItems(buildUserOutlineItems(), currentOutlineTitleHistory);
const historyState = currentOutlineHistoryState;
const historyStatus = historyState.loading
? '<div class="user-outline-loading" role="status">正在加载完整消息列表…</div>'
: historyState.error
? '<button type="button" class="user-outline-loading user-outline-retry" data-outline-retry>加载旧消息失败,点击重试</button>'
: historyState.cursor > 0 && !historyState.available
? '<div class="user-outline-loading" role="status">更早消息的原始记录不可用</div>'
: '';
if (items.length === 0) {
userOutlinePanel.innerHTML = '<div class="user-outline-empty">暂无用户消息</div>';
userOutlineBtn.disabled = true;
userOutlinePanel.innerHTML = historyStatus || '<div class="user-outline-empty">暂无用户消息</div>';
userOutlineBtn.disabled = !historyState.loading
&& !(historyState.cursor > 0 && historyState.available);
} else {
userOutlinePanel.innerHTML = items.map((item) => {
userOutlinePanel.innerHTML = `${items.map((item) => {
if (item.type === 'date') {
return `<div class="user-outline-date" role="separator" aria-label="${escapeHtml(item.label)}"><span>${escapeHtml(item.label)}</span></div>`;
}
@@ -2031,15 +2154,17 @@
`;
}
if (item.type === 'message') {
const messageIndex = Number(item.messageIndex);
const messageIndexAttr = Number.isFinite(messageIndex) ? ` data-message-index="${messageIndex}"` : '';
return `
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId || '')}"${messageIndexAttr}>
<span class="user-outline-index">${item.messageNumber}</span>
<span class="user-outline-text">${escapeHtml(item.label)}</span>
</button>
`;
}
return '';
}).join('');
}).join('')}${historyStatus}`;
userOutlineBtn.disabled = false;
}
}
@@ -2057,11 +2182,45 @@
closeCcwebPromptOutlinePanel();
userOutlinePanel.hidden = false;
userOutlineBtn.setAttribute('aria-expanded', 'true');
requestOutlineHistoryPage();
} else {
closeUserOutlinePanel();
}
}
function requestOutlineMessageTarget(messageIndex) {
const normalizedIndex = Number(messageIndex);
if (!currentSessionId || !Number.isFinite(normalizedIndex) || normalizedIndex < 0) return;
const target = messagesDiv.querySelector(
`[data-session-message="true"][data-message-index="${Math.trunc(normalizedIndex)}"]`,
);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (activeOutlineTargetRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return;
pendingAdvancedSearchJump = {
sessionId: currentSessionId,
messageIndex: Math.trunc(normalizedIndex),
query: '',
};
outlineHistoryRequestSeq += 1;
const requestId = `outline-target-${Date.now()}-${outlineHistoryRequestSeq}`;
activeOutlineTargetRequest = {
sessionId: currentSessionId,
requestId,
messageIndex: Math.trunc(normalizedIndex),
};
closeUserOutlinePanel();
send({
type: 'load_history_page',
sessionId: currentSessionId,
before: Math.trunc(normalizedIndex) + 1,
requestId,
purpose: 'outline-target',
});
}
function scrollToMessage(anchorId) {
if (!anchorId) return;
const target = document.getElementById(anchorId);
@@ -2403,7 +2562,7 @@
return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0);
}
function normalizeSessionSnapshot(payload, options = {}) {
function normalizeSessionSnapshot(payload) {
const sessionId = payload.sessionId || payload.id || '';
const messages = cloneMessages(payload.messages || []);
const historyTotal = Number.isFinite(Number(payload.historyTotal))
@@ -2412,6 +2571,16 @@
const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex))
? Math.max(0, Number(payload.historyBaseIndex))
: Math.max(0, historyTotal - messages.length);
const historyBuffered = Number.isFinite(Number(payload.historyBuffered))
? Math.max(0, Number(payload.historyBuffered))
: messages.length;
const historyCursor = Number.isFinite(Number(payload.historyCursor))
? Math.max(0, Number(payload.historyCursor))
: Math.max(0, historyBaseIndex);
const historyTruncated = payload.historyTruncated === true
|| historyCursor > 0
|| historyBaseIndex > 0;
const historyPending = !!payload.historyPending;
return {
sessionId,
id: sessionId,
@@ -2449,20 +2618,39 @@
|| payload.taskTracking?.enabled === true,
historyTotal,
historyBaseIndex,
historyBuffered: Number.isFinite(Number(payload.historyBuffered))
? Math.max(0, Number(payload.historyBuffered))
: messages.length,
historyCursor: Number.isFinite(Number(payload.historyCursor))
? Math.max(0, Number(payload.historyCursor))
: Math.max(0, historyBaseIndex),
historyTruncated: !!payload.historyTruncated,
historyBuffered,
historyCursor,
historyTruncated,
historyAvailable: payload.historyAvailable !== false,
historySource: payload.historySource || 'snapshot',
historyPending: !!payload.historyPending,
complete: options.complete !== undefined ? !!options.complete : !payload.historyPending,
historyPending,
complete: !historyPending
&& !historyTruncated
&& historyCursor === 0
&& historyBaseIndex === 0
&& historyBuffered >= historyTotal,
};
}
function isCompleteSessionSnapshot(snapshot) {
if (!snapshot || snapshot.complete !== true || snapshot.historyPending || snapshot.historyTruncated) return false;
const historyTotal = Number.isFinite(Number(snapshot.historyTotal))
? Math.max(0, Number(snapshot.historyTotal))
: Array.isArray(snapshot.messages) ? snapshot.messages.length : 0;
const historyBuffered = Number.isFinite(Number(snapshot.historyBuffered))
? Math.max(0, Number(snapshot.historyBuffered))
: Array.isArray(snapshot.messages) ? snapshot.messages.length : 0;
const historyCursor = Number.isFinite(Number(snapshot.historyCursor))
? Math.max(0, Number(snapshot.historyCursor))
: 0;
const historyBaseIndex = Number.isFinite(Number(snapshot.historyBaseIndex))
? Math.max(0, Number(snapshot.historyBaseIndex))
: 0;
return historyCursor === 0
&& historyBaseIndex === 0
&& historyBuffered >= historyTotal;
}
function touchSessionCache(sessionId) {
const entry = sessionCache.get(sessionId);
if (entry) entry.lastUsed = Date.now();
@@ -2471,6 +2659,43 @@
function invalidateSessionCache(sessionId) {
if (!sessionId) return;
sessionCache.delete(sessionId);
sessionHistoryBuffers.delete(sessionId);
}
function bufferSessionSnapshot(snapshot) {
if (!snapshot?.sessionId || isCompleteSessionSnapshot(snapshot)) {
if (snapshot?.sessionId) sessionHistoryBuffers.delete(snapshot.sessionId);
return;
}
const bufferedSnapshot = deepClone(snapshot);
const weight = estimateSessionSnapshotWeight(bufferedSnapshot);
if (weight > SESSION_CACHE_MAX_WEIGHT) {
sessionHistoryBuffers.delete(bufferedSnapshot.sessionId);
return;
}
sessionHistoryBuffers.set(bufferedSnapshot.sessionId, {
snapshot: bufferedSnapshot,
weight,
lastUsed: Date.now(),
});
while (sessionHistoryBuffers.size > SESSION_CACHE_LIMIT) {
let oldestId = null;
let oldestTs = Infinity;
for (const [sessionId, entry] of sessionHistoryBuffers) {
if ((entry.lastUsed || 0) < oldestTs) {
oldestTs = entry.lastUsed || 0;
oldestId = sessionId;
}
}
if (!oldestId) break;
sessionHistoryBuffers.delete(oldestId);
}
}
function getSessionHistoryBuffer(sessionId) {
const entry = sessionHistoryBuffers.get(sessionId);
if (entry) entry.lastUsed = Date.now();
return entry?.snapshot || null;
}
function pruneSessionCache() {
@@ -2492,7 +2717,7 @@
}
function cacheSessionSnapshot(snapshot) {
if (!snapshot?.sessionId || !snapshot.complete) return;
if (!snapshot?.sessionId || !isCompleteSessionSnapshot(snapshot)) return;
const cachedSnapshot = deepClone(snapshot);
const weight = estimateSessionSnapshotWeight(cachedSnapshot);
if (weight > SESSION_CACHE_MAX_WEIGHT) {
@@ -2507,6 +2732,7 @@
weight,
lastUsed: Date.now(),
});
sessionHistoryBuffers.delete(cachedSnapshot.sessionId);
pruneSessionCache();
}
@@ -2532,6 +2758,9 @@
const meta = getSessionMeta(sessionId);
entry.meta = meta ? deepClone(meta) : null;
}
for (const sessionId of sessionHistoryBuffers.keys()) {
if (!knownIds.has(sessionId)) sessionHistoryBuffers.delete(sessionId);
}
}
function mergeSessionListSnapshot(snapshot) {
@@ -2588,7 +2817,10 @@
function getSessionCacheDisposition(sessionId) {
const entry = sessionCache.get(sessionId);
const meta = getSessionMeta(sessionId);
if (!entry?.snapshot?.complete || !meta) return 'miss';
if (!entry?.snapshot || !isCompleteSessionSnapshot(entry.snapshot) || !meta) {
if (entry && !isCompleteSessionSnapshot(entry.snapshot)) sessionCache.delete(sessionId);
return 'miss';
}
if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) {
return 'strong';
}
@@ -6299,6 +6531,7 @@
closeFileBrowser();
currentSessionId = null;
resetHistoryLoadState(null);
resetOutlineHistoryState(null);
syncTaskTrackingControl(null);
loadedHistorySessionId = null;
currentSessionMessageCount = 0;
@@ -6326,7 +6559,9 @@
updateReloadMcpButtonUI();
// 真正替换聊天 DOM 时,使之前 renderMessages 的异步批次失效。
renderEpoch++;
messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd);
messagesDiv.innerHTML = '';
if (historyLoadMore) messagesDiv.appendChild(historyLoadMore);
messagesDiv.insertAdjacentHTML('beforeend', buildWelcomeMarkup(currentCwd));
setStatsDisplay(null);
renderPendingAttachments();
renderPendingNotes({ scroll: false });
@@ -6358,9 +6593,13 @@
activeToolCalls.clear();
activeTodoCallTargets.clear();
}
if (currentSessionId !== snapshot.sessionId) resetHistoryLoadState(snapshot.sessionId);
if (currentSessionId !== snapshot.sessionId) {
resetHistoryLoadState(snapshot.sessionId);
resetOutlineHistoryState(snapshot.sessionId);
}
currentSessionId = snapshot.sessionId;
syncHistoryLoadControl(snapshot);
syncOutlineHistoryIndex(snapshot);
syncTaskTrackingControl(snapshot);
loadedHistorySessionId = snapshot.sessionId;
currentOutlineTitleHistory = normalizeOutlineTitleHistory(snapshot.titleHistory);
@@ -6651,27 +6890,96 @@
});
}
function requestOutlineHistoryPage() {
const state = currentOutlineHistoryState;
if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0 || !state.available) return;
if (activeOutlineHistoryRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return;
outlineHistoryRequestSeq += 1;
const requestId = `outline-history-${Date.now()}-${outlineHistoryRequestSeq}`;
activeOutlineHistoryRequest = {
sessionId: state.sessionId,
requestId,
before: state.cursor,
};
currentOutlineHistoryState = { ...state, loading: true, error: '' };
updateUserOutlinePanel();
send({
type: 'load_history_page',
sessionId: state.sessionId,
before: state.cursor,
requestId,
purpose: 'outline',
});
}
function mergeHistoryChunkIntoCachedSnapshot(sessionId, messages, options = {}) {
if (!sessionId || !Array.isArray(messages) || messages.length === 0) return;
updateCachedSession(sessionId, (snapshot) => {
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const indexed = new Map();
existingMessages.forEach((message, index) => indexed.set(existingBase + index, message));
messages.forEach((message, index) => indexed.set(incomingBase + index, message));
const indexes = Array.from(indexed.keys()).sort((a, b) => a - b);
snapshot.messages = indexes.map((index) => indexed.get(index));
snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total));
if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor));
snapshot.historyTruncated = snapshot.historyCursor > 0;
const cachedSnapshot = sessionCache.get(sessionId)?.snapshot || null;
const bufferedSnapshot = getSessionHistoryBuffer(sessionId);
const snapshot = cachedSnapshot || bufferedSnapshot;
if (!snapshot) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const indexed = new Map();
existingMessages.forEach((message, index) => indexed.set(existingBase + index, message));
messages.forEach((message, index) => indexed.set(incomingBase + index, message));
const indexes = Array.from(indexed.keys()).sort((a, b) => a - b);
snapshot.messages = indexes.map((index) => indexed.get(index));
snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total));
if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor));
snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0;
snapshot.historyPending = false;
snapshot.complete = snapshot.historyCursor === 0
&& !snapshot.historyTruncated
&& snapshot.historyBuffered >= snapshot.historyTotal;
if (isCompleteSessionSnapshot(snapshot)) {
cacheSessionSnapshot(snapshot);
} else {
if (cachedSnapshot) sessionCache.delete(sessionId);
bufferSessionSnapshot(snapshot);
}
}
function mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, options = {}) {
if (!snapshot || !Array.isArray(messages) || messages.length === 0) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const indexed = new Map();
existingMessages.forEach((message, index) => indexed.set(existingBase + index, message));
messages.forEach((message, index) => indexed.set(incomingBase + index, message));
const indexes = Array.from(indexed.keys()).sort((a, b) => a - b);
snapshot.messages = indexes.map((index) => indexed.get(index));
snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
if (Number.isFinite(Number(options.total))) {
snapshot.historyTotal = Math.max(0, Number(options.total));
}
if (Number.isFinite(Number(options.cursor))) {
snapshot.historyCursor = Math.max(0, Number(options.cursor));
} else if (options.lastChunk) {
snapshot.historyCursor = Math.max(0, snapshot.historyBaseIndex);
}
if (options.lastChunk) {
snapshot.historyPending = false;
snapshot.complete = snapshot.historyCursor === 0;
});
snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0;
if (!Number.isFinite(Number(snapshot.historyTotal))) {
snapshot.historyTotal = snapshot.messages.length;
}
snapshot.complete = snapshot.historyCursor === 0
&& !snapshot.historyTruncated
&& snapshot.historyBuffered >= snapshot.historyTotal;
} else {
snapshot.historyPending = true;
snapshot.complete = false;
}
}
function createSessionSwitchRequestId(sessionId) {
@@ -6716,7 +7024,6 @@
return;
}
if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) {
activeSessionLoad.snapshot.complete = true;
cacheSessionSnapshot(activeSessionLoad.snapshot);
}
finishSessionSwitch(sessionId, requestId);
@@ -7356,6 +7663,41 @@
reconnectTimer = null;
}
function clearWsHeartbeat() {
if (wsHeartbeatTimer) {
clearInterval(wsHeartbeatTimer);
wsHeartbeatTimer = null;
}
wsHeartbeatPending = null;
}
function sendWsHeartbeat(socket) {
if (!wsSupportsClientHeartbeat || ws !== socket || socket.readyState !== 1 || !wsAuthenticated) return;
const now = Date.now();
if (wsHeartbeatPending) {
if (now - wsHeartbeatPending.sentAt <= WS_CLIENT_HEARTBEAT_TIMEOUT_MS) return;
wsHeartbeatPending = null;
try { socket.close(4000, 'heartbeat timeout'); } catch {}
return;
}
wsHeartbeatSeq += 1;
const requestId = `client-heartbeat-${Date.now()}-${wsHeartbeatSeq}`;
wsHeartbeatPending = { requestId, sentAt: now };
try {
socket.send(JSON.stringify({ type: 'client_heartbeat', requestId }));
} catch {
wsHeartbeatPending = null;
try { socket.close(4000, 'heartbeat send failed'); } catch {}
}
}
function startWsHeartbeat(socket) {
clearWsHeartbeat();
if (!wsSupportsClientHeartbeat) return;
sendWsHeartbeat(socket);
wsHeartbeatTimer = setInterval(() => sendWsHeartbeat(socket), WS_CLIENT_HEARTBEAT_INTERVAL_MS);
}
function connect() {
if (!canConnectWs()) return;
if (ws && ws.readyState <= 1) return;
@@ -7364,6 +7706,7 @@
const socket = new WebSocket(WS_URL);
ws = socket;
wsAuthenticated = false;
wsSupportsClientHeartbeat = false;
socket.onopen = () => {
if (ws !== socket) return;
@@ -7390,6 +7733,27 @@
if (ws !== socket) return;
ws = null;
wsAuthenticated = false;
wsSupportsClientHeartbeat = false;
clearWsHeartbeat();
if (activeOutlineHistoryRequest) {
activeOutlineHistoryRequest = null;
currentOutlineHistoryState = {
...currentOutlineHistoryState,
loading: false,
error: '连接中断,连接恢复后可重试',
};
if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel();
}
if (activeOutlineTargetRequest) {
activeOutlineTargetRequest = null;
pendingAdvancedSearchJump = null;
if (!isPageUnloading) {
appendError('连接中断,定位消息加载失败;连接恢复后请重新点击该条目。', {
transient: true,
autoDismissMs: 7000,
});
}
}
if (activeHistoryPageRequest) {
activeHistoryPageRequest = null;
currentHistoryState = {
@@ -7408,6 +7772,11 @@
recoverCurrent: activeSessionLoad.recoverCurrent === true,
targetMessageIndex: activeSessionLoad.targetMessageIndex,
};
const now = Date.now();
if (now - lastWsDisconnectNoticeAt >= 5000) {
lastWsDisconnectNoticeAt = now;
showToast('连接中断,正在重连并自动重试会话切换…');
}
} else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) {
pendingSessionResumeRequest = {
sessionId: currentSessionId,
@@ -7490,6 +7859,12 @@
function handleServerMessage(msg) {
if (handleTaskBoardProtocolMessage(msg)) return;
switch (msg.type) {
case 'client_heartbeat_ack':
if (wsHeartbeatPending?.requestId === String(msg.requestId || '')) {
wsHeartbeatPending = null;
}
break;
case 'auth_result':
if (msg.success) {
if (shouldReloadForFrontendAssetVersion(msg.frontendAssetVersion)) {
@@ -7499,6 +7874,8 @@
const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId;
authToken = msg.token;
wsAuthenticated = true;
wsSupportsClientHeartbeat = msg.features?.clientHeartbeat === true;
startWsHeartbeat(ws);
localStorage.setItem('cc-web-token', msg.token);
document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
loginOverlay.hidden = true;
@@ -7525,6 +7902,8 @@
pendingInitialSessionLoad = shouldLoadInitialSession;
}
} else {
clearWsHeartbeat();
wsSupportsClientHeartbeat = false;
pendingSessionSwitchRequest = null;
pendingSessionResumeRequest = null;
clearSessionResumeFallbackTimer();
@@ -7621,6 +8000,11 @@
break;
}
mergeSessionListSnapshot(snapshot);
if (isCompleteSessionSnapshot(snapshot)) {
sessionHistoryBuffers.delete(snapshot.sessionId);
} else {
bufferSessionSnapshot(snapshot);
}
if (matchesActiveLoad) {
activeSessionLoad.snapshot = snapshot;
}
@@ -7659,13 +8043,77 @@
const matchesManualHistoryLoad = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest
&& activeHistoryPageRequest.sessionId === msg.sessionId
&& activeHistoryPageRequest.requestId === historyRequestId);
const matchesOutlineHistoryLoad = !!(activeOutlineHistoryRequest
&& activeOutlineHistoryRequest.sessionId === msg.sessionId
&& activeOutlineHistoryRequest.requestId === historyRequestId);
const matchesOutlineTargetLoad = !!(activeOutlineTargetRequest
&& activeOutlineTargetRequest.sessionId === msg.sessionId
&& activeOutlineTargetRequest.requestId === historyRequestId);
const allowsLegacyHistory = !historyRequestId
&& msg.sessionId === currentSessionId
&& loadedHistorySessionId === msg.sessionId;
if (matchesOutlineTargetLoad) {
if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
const baseIndex = Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: 0;
prependHistoryMessages(msg.messages || [], {
preserveScroll: false,
baseIndex,
});
mergeOutlineHistoryMessages(msg.messages || [], baseIndex);
mergeHistoryChunkIntoCachedSnapshot(msg.sessionId, msg.messages || [], {
baseIndex,
total: msg.historyTotal,
});
activeOutlineTargetRequest = null;
scheduleAdvancedSearchJump();
if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel();
} else {
activeOutlineTargetRequest = null;
pendingAdvancedSearchJump = null;
}
break;
}
if (matchesOutlineHistoryLoad) {
if (msg.sessionId === currentSessionId && currentOutlineHistoryState.sessionId === msg.sessionId) {
const nextCursor = Number.isFinite(Number(msg.historyCursor))
? Math.max(0, Number(msg.historyCursor))
: Math.max(0, activeOutlineHistoryRequest.before - (msg.messages || []).length);
mergeOutlineHistoryMessages(
msg.messages || [],
Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
);
activeOutlineHistoryRequest = null;
currentOutlineHistoryState = {
...currentOutlineHistoryState,
cursor: nextCursor,
total: Number.isFinite(Number(msg.historyTotal))
? Math.max(currentOutlineHistoryState.total, Number(msg.historyTotal))
: currentOutlineHistoryState.total,
available: msg.historyAvailable !== false,
loading: false,
complete: nextCursor === 0,
error: '',
};
if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel();
if (nextCursor > 0) requestOutlineHistoryPage();
} else {
activeOutlineHistoryRequest = null;
}
break;
}
if (activeSessionLoad?.recoverCurrent && matchesActiveHistoryLoad) {
if (activeSessionLoad.snapshot) {
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || [])
.concat(activeSessionLoad.snapshot.messages);
mergeHistoryChunkIntoSessionLoadSnapshot(activeSessionLoad.snapshot, msg.messages || [], {
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
cursor: msg.historyCursor,
total: msg.historyTotal,
lastChunk: !msg.remaining,
});
if (!isCompleteSessionSnapshot(activeSessionLoad.snapshot)) {
bufferSessionSnapshot(activeSessionLoad.snapshot);
}
}
if (!msg.remaining) finalizeLoadedSession(msg.sessionId, historyRequestId || undefined);
break;
@@ -7675,13 +8123,25 @@
&& loadedHistorySessionId === msg.sessionId) {
const blocking = isBlockingSessionLoad(msg.sessionId);
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages);
mergeHistoryChunkIntoSessionLoadSnapshot(activeSessionLoad.snapshot, msg.messages || [], {
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
cursor: msg.historyCursor,
total: msg.historyTotal,
lastChunk: !msg.remaining,
});
if (!isCompleteSessionSnapshot(activeSessionLoad.snapshot)) {
bufferSessionSnapshot(activeSessionLoad.snapshot);
}
}
prependHistoryMessages(msg.messages || [], {
preserveScroll: !blocking,
skipScrollbar: blocking,
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
});
mergeOutlineHistoryMessages(
msg.messages || [],
Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
);
scheduleAdvancedSearchJump();
if (matchesManualHistoryLoad) {
const nextCursor = Number.isFinite(Number(msg.historyCursor))
@@ -8004,7 +8464,40 @@
break;
case 'error':
// 兼容尚未重启的旧服务端:它会把新客户端心跳当成未知消息。
// 该错误不代表业务请求失败,不能污染聊天区或触发切换失败提示。
if (/^Unknown type:\s*client_heartbeat\s*$/i.test(String(msg.message || ''))) {
wsSupportsClientHeartbeat = false;
wsHeartbeatPending = null;
clearWsHeartbeat();
break;
}
const errorRequestId = String(msg.requestId || '');
const matchesOutlineTargetError = !!(activeOutlineTargetRequest
&& (!msg.sessionId || msg.sessionId === activeOutlineTargetRequest.sessionId)
&& errorRequestId === activeOutlineTargetRequest.requestId);
if (matchesOutlineTargetError) {
activeOutlineTargetRequest = null;
pendingAdvancedSearchJump = null;
appendError(msg.message || '加载定位消息失败,请重试', {
transient: true,
autoDismissMs: 7000,
});
break;
}
const matchesOutlineHistoryError = !!(activeOutlineHistoryRequest
&& (!msg.sessionId || msg.sessionId === activeOutlineHistoryRequest.sessionId)
&& errorRequestId === activeOutlineHistoryRequest.requestId);
if (matchesOutlineHistoryError) {
activeOutlineHistoryRequest = null;
currentOutlineHistoryState = {
...currentOutlineHistoryState,
loading: false,
error: msg.message || '加载定位历史失败,请重试',
};
if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel();
break;
}
const matchesHistoryPageError = !!(typeof activeHistoryPageRequest !== 'undefined' && activeHistoryPageRequest
&& (!msg.sessionId || msg.sessionId === activeHistoryPageRequest.sessionId)
&& errorRequestId === activeHistoryPageRequest.requestId);
@@ -10214,9 +10707,10 @@
collabAgentIdsByToolUseId = new Map();
closedCollabAgentIdsByToolUseId = new Map();
messagesDiv.innerHTML = '';
if (historyLoadMore) messagesDiv.appendChild(historyLoadMore);
clearUserMessageIndex();
if (messages.length === 0) {
messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd);
messagesDiv.insertAdjacentHTML('beforeend', buildWelcomeMarkup(currentCwd));
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
@@ -10265,7 +10759,7 @@
const prevScrollTop = messagesDiv.scrollTop;
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i));
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
// Compensate scrollTop so visible area stays unchanged
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
@@ -10296,14 +10790,14 @@
const frag = document.createDocumentFragment();
uniqueMessages.forEach(({ message, index }) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
if (!preserveScroll) {
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
if (!skipScrollbar) updateScrollbar();
return;
}
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
if (!skipScrollbar) updateScrollbar();
@@ -11315,8 +11809,14 @@
}
function highlightActiveSession() {
let activeAssigned = false;
document.querySelectorAll('.session-item').forEach((el) => {
el.classList.toggle('active', el.dataset.id === currentSessionId);
const isTarget = el.dataset.id === currentSessionId;
const isActive = isTarget && !activeAssigned;
if (isActive) activeAssigned = true;
el.classList.toggle('active', isActive);
if (isActive) el.setAttribute('aria-current', 'true');
else el.removeAttribute('aria-current');
});
}
@@ -12036,11 +12536,19 @@
toggleUserOutlinePanel();
});
userOutlinePanel.addEventListener('click', (e) => {
const retry = e.target instanceof HTMLElement ? e.target.closest('[data-outline-retry]') : null;
if (retry) {
e.preventDefault();
requestOutlineHistoryPage();
return;
}
const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null;
if (!target) return;
const anchorId = target.getAttribute('data-target') || '';
const messageIndex = Number(target.getAttribute('data-message-index'));
closeUserOutlinePanel();
scrollToMessage(anchorId);
if (anchorId) scrollToMessage(anchorId);
else if (Number.isFinite(messageIndex)) requestOutlineMessageTarget(messageIndex);
});
}