fix session switch race on message send

This commit is contained in:
shiyue
2026-06-24 09:54:11 +08:00
parent 01c7fdd27a
commit 2f02270edc
4 changed files with 176 additions and 119 deletions

View File

@@ -67,7 +67,7 @@
const SESSION_CACHE_MAX_WEIGHT = 1_500_000;
const SIDEBAR_SWIPE_TRIGGER = 72;
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
const OLD_SESSION_COLLAPSE_VISIBLE_LIMIT = 5;
const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3;
const OLD_SESSION_COLLAPSE_DAYS = 7;
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
@@ -194,6 +194,7 @@
let codexAppApprovalModal = null;
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let sessionSwitchRequestSeq = 0;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
let initialSessionListHandled = false;
@@ -1630,6 +1631,44 @@
}
}
function mergeSessionListSnapshot(snapshot) {
if (!snapshot?.sessionId) return;
const nextMeta = {
id: snapshot.sessionId,
sessionId: snapshot.sessionId,
cwd: snapshot.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : snapshot.projectName || '',
title: snapshot.title || '新会话',
agent: normalizeAgent(snapshot.agent),
updated: snapshot.updated || new Date().toISOString(),
pinnedAt: snapshot.pinnedAt || null,
hasUnread: !!snapshot.hasUnread,
isRunning: !!snapshot.isRunning,
waitingOnChildren: !!snapshot.waitingOnChildren,
pendingReplyCount: Number(snapshot.pendingReplyCount || 0),
readyReplyCount: Number(snapshot.readyReplyCount || 0),
waitingReplyCount: Number(snapshot.waitingReplyCount || 0),
failedReplyCount: Number(snapshot.failedReplyCount || 0),
oversized: !!snapshot.oversized,
fileBytes: Number(snapshot.fileBytes || 0),
};
let found = false;
sessions = sessions.map((session) => {
if (session.id !== snapshot.sessionId) return session;
found = true;
return {
...session,
...nextMeta,
cwd: nextMeta.cwd || session.cwd || '',
projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
title: nextMeta.title || session.title,
};
});
if (!found) {
sessions = [nextMeta, ...sessions].sort(compareSessionUpdatedDesc);
}
}
function getSessionCacheDisposition(sessionId) {
const entry = sessionCache.get(sessionId);
const meta = getSessionMeta(sessionId);
@@ -3009,30 +3048,36 @@
return `${normalizeAgent(currentAgent)}:ungrouped`;
}
function splitCollapsedOldSessions(regularSessions, pinnedCount, getCollapseKey = () => '') {
const totalCount = pinnedCount + regularSessions.length;
if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) {
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
}
function expandOldSessionGroup(collapseKey) {
if (!collapseKey) return;
expandedOldSessionGroups.add(collapseKey);
}
function shouldAlwaysShowOldSession(session) {
return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
}
function splitCollapsedOldSessions(sessionItems, collapseKey) {
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
const nowMs = Date.now();
const visibleRegularLimit = Math.max(0, OLD_SESSION_COLLAPSE_VISIBLE_LIMIT - pinnedCount);
const visibleRegularSessions = [];
const visibleSessions = [];
const hiddenOldSessions = [];
regularSessions.forEach((session, index) => {
const collapseKey = getCollapseKey(session);
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
const canCollapse = !isExpanded && index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
if (canCollapse && !shouldKeepVisible) {
sessionItems.forEach((session) => {
const shouldHideOldSession = (
!isExpanded
&& !shouldAlwaysShowOldSession(session)
&& isOlderThanOldSessionWindow(session, nowMs)
&& visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE
);
if (shouldHideOldSession) {
hiddenOldSessions.push(session);
} else {
visibleRegularSessions.push(session);
visibleSessions.push(session);
}
});
return { visibleRegularSessions, hiddenOldSessions };
return { visibleSessions, hiddenOldSessions };
}
function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
@@ -3042,10 +3087,10 @@
button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''} ${hiddenCount} 条 7 天前会话`);
button.innerHTML = `
<span class="session-list-load-more-title">加载更多</span>
<span class="session-list-load-more-meta">${hiddenCount} 7 天前会话</span>
<span class="session-list-load-more-meta">还有 ${hiddenCount} 条</span>
`;
button.addEventListener('click', () => {
if (collapseKey) expandedOldSessionGroups.add(collapseKey);
expandOldSessionGroup(collapseKey);
renderSessionList();
});
return button;
@@ -3393,7 +3438,8 @@
function setSessionLoading(sessionId, options = {}) {
const loading = !!sessionId;
const blocking = options.blocking !== false;
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null } : null;
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId } : null;
const showOverlay = !!(loading && blocking);
document.body.classList.toggle('session-loading-active', showOverlay);
sessionLoadingOverlay.hidden = !showOverlay;
@@ -3413,6 +3459,11 @@
setSessionLoading(null, { blocking: false });
}
function createSessionSwitchRequestId(sessionId) {
sessionSwitchRequestSeq += 1;
return `session-load-${Date.now()}-${sessionSwitchRequestSeq}-${String(sessionId || '').slice(0, 8)}`;
}
function isBlockingSessionLoad(sessionId) {
return !!(activeSessionLoad &&
activeSessionLoad.blocking &&
@@ -3454,6 +3505,7 @@
sessionId,
blocking: options.blocking !== false,
label: options.label || '',
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
};
if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionSwitch();
@@ -3471,9 +3523,10 @@
setSessionLoading(request.sessionId, {
blocking: request.blocking,
label: request.label || undefined,
requestId: request.requestId,
});
}
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId }));
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId }));
}
function showCachedSession(sessionId) {
@@ -3971,6 +4024,7 @@
sessionId: activeSessionLoad.sessionId,
blocking: activeSessionLoad.blocking,
label: sessionLoadingLabel?.textContent || '',
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
};
}
clearSessionLoading();
@@ -4068,28 +4122,29 @@
break;
case 'session_info':
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
const snapshot = normalizeSessionSnapshot(msg);
sessions = sessions.map((session) => (
session.id === snapshot.sessionId
? {
...session,
cwd: snapshot.cwd || session.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
isRunning: snapshot.isRunning,
waitingOnChildren: snapshot.waitingOnChildren,
pendingReplyCount: snapshot.pendingReplyCount,
readyReplyCount: snapshot.readyReplyCount,
waitingReplyCount: snapshot.waitingReplyCount,
failedReplyCount: snapshot.failedReplyCount,
}
: session
));
if (activeSessionLoad?.sessionId === msg.sessionId) {
const activeLoad = activeSessionLoad;
const pendingNewSession = pendingNewSessionRequest;
const messageRequestId = String(msg.requestId || '');
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
&& (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
const matchesPendingNewSession = !!(pendingNewSession
&& (!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId));
const canSwitchToSessionInfo = matchesActiveLoad
|| matchesPendingNewSession
|| msg.sessionId === currentSessionId
|| (!currentSessionId && !activeLoad && !pendingNewSession)
|| (!messageRequestId && !activeLoad && !pendingNewSession);
mergeSessionListSnapshot(snapshot);
if (matchesActiveLoad) {
activeSessionLoad.snapshot = snapshot;
}
if (!canSwitchToSessionInfo) {
if (!msg.historyPending) cacheSessionSnapshot(snapshot);
renderSessionList();
break;
}
if (matchesPendingNewSession) pendingNewSessionRequest = null;
applySessionSnapshot(snapshot, {
immediate: isBlockingSessionLoad(msg.sessionId),
suppressUnreadToast: false,
@@ -4099,7 +4154,7 @@
setCurrentSessionRunningState(!!msg.isRunning);
}
if (!msg.historyPending) {
if (activeSessionLoad?.sessionId === msg.sessionId) {
if (matchesActiveLoad) {
finalizeLoadedSession(msg.sessionId);
} else {
cacheSessionSnapshot(snapshot);
@@ -4380,6 +4435,7 @@
agent: request.agent,
mode: request.mode,
createCwd: true,
requestId: request.requestId,
});
},
onCancel: () => {
@@ -6515,33 +6571,6 @@
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
const oldSessionCollapseKeysBySessionId = new Map();
projectGroups.forEach((group) => {
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
group.sessions.forEach((session) => {
oldSessionCollapseKeysBySessionId.set(session.id, oldSessionCollapseKey);
});
});
ungroupedSessions.forEach((session) => {
oldSessionCollapseKeysBySessionId.set(session.id, getUngroupedOldSessionCollapseKey());
});
const { visibleRegularSessions, hiddenOldSessions } = isSearchingSessions
? { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }
: splitCollapsedOldSessions(
regularSessions,
pinnedSessions.length,
(session) => oldSessionCollapseKeysBySessionId.get(session.id) || ''
);
const hiddenOldSessionCountsByKey = new Map();
hiddenOldSessions.forEach((session) => {
const oldSessionCollapseKey = oldSessionCollapseKeysBySessionId.get(session.id);
if (!oldSessionCollapseKey) return;
hiddenOldSessionCountsByKey.set(
oldSessionCollapseKey,
(hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0) + 1
);
});
const visibleRegularSessionIds = new Set(visibleRegularSessions.map((session) => session.id));
if (pinnedSessions.length > 0) {
const pinnedGroupEl = document.createElement('section');
pinnedGroupEl.className = 'session-project-group session-pinned-group';
@@ -6563,8 +6592,9 @@
projectGroups.forEach((group, groupIndex) => {
const groupKey = getProjectCollapseKey(group);
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
const visibleGroupSessions = group.sessions.filter((session) => visibleRegularSessionIds.has(session.id));
const hiddenGroupOldSessionCount = hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0;
const { visibleSessions: visibleGroupSessions, hiddenOldSessions: hiddenGroupOldSessions } = isSearchingSessions
? { visibleSessions: group.sessions, hiddenOldSessions: [] }
: splitCollapsedOldSessions(group.sessions, oldSessionCollapseKey);
const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
@@ -6596,8 +6626,8 @@
for (const s of visibleGroupSessions) {
groupBody.appendChild(createSessionListItem(s));
}
if (hiddenGroupOldSessionCount > 0) {
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupOldSessionCount, oldSessionCollapseKey, group.name));
if (hiddenGroupOldSessions.length > 0) {
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupOldSessions.length, oldSessionCollapseKey, group.name));
}
groupEl.appendChild(groupBody);
@@ -6614,15 +6644,16 @@
});
const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
const visibleUngroupedSessions = ungroupedSessions.filter((session) => visibleRegularSessionIds.has(session.id));
const hiddenUngroupedOldSessionCount = hiddenOldSessionCountsByKey.get(ungroupedCollapseKey) || 0;
const { visibleSessions: visibleUngroupedSessions, hiddenOldSessions: hiddenUngroupedOldSessions } = isSearchingSessions
? { visibleSessions: ungroupedSessions, hiddenOldSessions: [] }
: splitCollapsedOldSessions(ungroupedSessions, ungroupedCollapseKey);
for (const s of visibleUngroupedSessions) {
sessionList.appendChild(createSessionListItem(s));
}
if (hiddenUngroupedOldSessionCount > 0) {
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedOldSessionCount, ungroupedCollapseKey));
if (hiddenUngroupedOldSessions.length > 0) {
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedOldSessions.length, ungroupedCollapseKey));
}
}
@@ -8583,14 +8614,16 @@
const rawCwd = options.rawCwd !== undefined ? options.rawCwd : (cwd || '');
const agent = normalizeAgent(options.agent || currentAgent);
const mode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode;
const requestId = createSessionSwitchRequestId('new');
pendingNewSessionRequest = {
cwd,
rawCwd,
agent,
mode,
requestId,
};
if (cwd) saveRecentCwd(cwd);
send({ type: 'new_session', cwd, agent, mode });
send({ type: 'new_session', cwd, agent, mode, requestId });
}
// --- New Session Modal ---

View File

@@ -200,8 +200,8 @@ html[data-theme='coolvibe'] .session-search-clear:focus-visible {
html[data-theme='coolvibe'] .session-project-header {
background: linear-gradient(180deg, rgba(247, 251, 252, 0.94), rgba(239, 248, 250, 0.88));
color: #5f7f87;
border-bottom: 1px solid rgba(191, 220, 228, 0.56);
border-color: rgba(156, 199, 211, 0.9);
color: #335e69;
}
html[data-theme='coolvibe'] .session-project-count {
@@ -337,8 +337,8 @@ html[data-theme='coolvibe'] .theme-card.active {
html[data-theme='editorial'] .session-project-header {
background: linear-gradient(180deg, rgba(239, 232, 220, 0.94), rgba(246, 241, 232, 0.84));
color: #7f6f61;
border-bottom: 1px solid rgba(139, 94, 60, 0.12);
border-color: rgba(139, 94, 60, 0.22);
color: #4f4035;
}
html[data-theme='editorial'] .session-project-count {
@@ -1146,15 +1146,18 @@ body.session-loading-active {
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 4px 2px 5px;
padding: 6px 8px 5px;
background: rgba(242, 235, 226, 0.92);
margin: 6px 3px 6px;
padding: 6px 8px;
border: 1px solid rgba(134, 106, 80, 0.22);
border-radius: 8px;
background: rgba(255, 249, 242, 0.88);
backdrop-filter: blur(8px);
color: var(--text-muted);
font-size: 11px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.72), 0 1px 6px rgba(45, 31, 20, 0.04);
color: var(--text-secondary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
letter-spacing: 0;
text-transform: none;
}
.session-project-name {
min-width: 0;
@@ -1162,14 +1165,15 @@ body.session-loading-active {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
color: var(--text-primary);
}
.session-project-toggle {
min-width: 0;
flex: 1;
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
gap: 6px;
height: 26px;
padding: 0;
border: 0;
background: transparent;
@@ -1182,9 +1186,13 @@ body.session-loading-active {
}
.session-project-toggle:hover,
.session-project-toggle:focus-visible {
color: var(--text-secondary);
color: var(--accent);
outline: none;
}
.session-project-toggle:hover .session-project-name,
.session-project-toggle:focus-visible .session-project-name {
color: var(--accent);
}
.session-project-toggle:focus-visible .session-project-name {
text-decoration: underline;
text-underline-offset: 3px;
@@ -1192,8 +1200,8 @@ body.session-loading-active {
.session-project-chevron {
width: 12px;
flex-shrink: 0;
color: var(--text-muted);
font-size: 10px;
color: currentColor;
font-size: 11px;
line-height: 1;
text-align: center;
transform: translateY(-0.5px);
@@ -1211,9 +1219,10 @@ body.session-loading-active {
min-width: 22px;
height: 18px;
padding: 0 7px;
border: 1px solid rgba(134, 106, 80, 0.14);
border-radius: 999px;
background: var(--bg-tertiary);
color: var(--text-secondary);
background: rgba(45, 31, 20, 0.06);
color: var(--text-primary);
font-size: 10px;
letter-spacing: 0;
}
@@ -1244,6 +1253,9 @@ body.session-loading-active {
.session-pinned-header {
color: var(--accent);
}
.session-pinned-header .session-project-name {
color: var(--accent);
}
.session-project-sessions[hidden] {
display: none;
}
@@ -1256,6 +1268,9 @@ body.session-loading-active {
.session-project-group.has-active-session.collapsed .session-project-header {
color: var(--accent);
}
.session-project-group.has-active-session.collapsed .session-project-name {
color: var(--accent);
}
.session-project-group.has-active-session.collapsed .session-project-count {
background: var(--accent-light);
color: var(--accent);
@@ -1460,23 +1475,24 @@ body.session-loading-active {
color: var(--danger);
}
.session-list-load-more {
width: calc(100% - 4px);
margin: 6px 2px 10px;
padding: 9px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(255, 249, 242, 0.72);
width: fit-content;
max-width: calc(100% - 18px);
margin: 2px 8px 6px;
padding: 3px 7px;
border: 1px solid rgba(134, 106, 80, 0.18);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
justify-content: flex-start;
gap: 6px;
text-align: left;
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s;
}
.session-list-load-more:hover {
background: var(--bg-tertiary);
background: rgba(255, 249, 242, 0.58);
border-color: rgba(192, 85, 58, 0.24);
color: var(--accent);
transform: translateY(-1px);
@@ -1487,13 +1503,13 @@ body.session-loading-active {
}
.session-list-load-more-title {
min-width: 0;
font-size: 13px;
font-size: 12px;
font-weight: 700;
}
.session-list-load-more-meta {
flex-shrink: 0;
color: var(--text-muted);
font-size: 11px;
font-size: 10px;
}
/* Inline edit in sidebar */
.session-item-edit-input {