diff --git a/web/src/App.jsx b/web/src/App.jsx
index 0dfb4af3..7aafb6b0 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -515,8 +515,8 @@ export function App() {
setActiveRef(next);
}, [resetSidebarSessionLoading]);
- const navigateToRef = useCallback((nextRefOrUpdater) => {
- resetSidebarSessionLoading();
+ const navigateToRef = useCallback((nextRefOrUpdater, options = {}) => {
+ if (!options.preserveSidebarSessionLoading) resetSidebarSessionLoading();
const current = activeRefRef.current;
const next = typeof nextRefOrUpdater === 'function'
? nextRefOrUpdater(current)
diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx
index 3590df6c..61cc18f1 100644
--- a/web/src/components/Sidebar.jsx
+++ b/web/src/components/Sidebar.jsx
@@ -688,8 +688,22 @@ function SidebarSectionHeader({ sectionId, count, expanded, onToggle, actions =
}
function SessionAttentionIndicator({ attention, meta }) {
+ if (attention !== 'in_progress' && attention !== 'unread') return null;
+
return attention === 'in_progress' ? (
-
+
+
+
+
+
+
+
+
) : (
);
@@ -1275,10 +1289,11 @@ function SessionRow({
) : !editing && pendingQuestion ? (
- 等待回复
+ {tr('sessionNavigation.pendingReply')}
) : null}
{showSessionTime && !editing && !pendingPermission && !pendingQuestion && (
@@ -2000,14 +2015,15 @@ export function Sidebar({
useEffect(() => {
const intent = sessionSelectionIntentRef.current;
if (!intent) return;
- if (sidebarRevealTargetKey(revealTarget) === intent.revealKey) {
+ const revealedIntent = sidebarRevealTargetKey(revealTarget) === intent.revealKey;
+ if (revealedIntent && activeRef?.resumePending !== true) {
cancelSessionSelection({ cancelPending: false });
return;
}
- if (activeNavigationIdentity !== intent.baselineNavigationIdentity) {
+ if (!revealedIntent && activeNavigationIdentity !== intent.baselineNavigationIdentity) {
cancelSessionSelection();
}
- }, [activeNavigationIdentity, cancelSessionSelection, revealTarget]);
+ }, [activeNavigationIdentity, activeRef?.resumePending, cancelSessionSelection, revealTarget]);
useEffect(() => {
if (handledSessionLoadResetSequenceRef.current === sessionLoadResetSequence) return;
@@ -3321,45 +3337,61 @@ export function Sidebar({
}
}
- let selectedTarget = target;
+ if (target.noWorkspace) {
+ setActiveWorkspaceHash('');
+ setWorkspaces((prev) => prev.map((item) => ({ ...item, active: false })));
+ }
+ markSessionRead({
+ ...(target.noWorkspace ? normalizeNoWorkspaceSession(session) : session),
+ id: target.sessionId,
+ workspace_hash: target.workspaceHash,
+ cwd: target.cwd,
+ });
+ // 会话历史可直接从磁盘读取,不需要等运行时恢复完成。先切换主内容,
+ // 把耗时的 Provider / hook 恢复留在后台,避免一次点击被阻塞数秒。
+ onSelect?.({
+ ...target,
+ // 运行时尚未恢复时不要提前声明 active,否则 ChatView 会立即
+ // 建立实时连接,而 daemon 尚未注册该会话并返回 unknown session。
+ active: session.active === true,
+ resumePending: session.active !== true,
+ ...expertReferenceForSession(session),
+ }, { preserveSidebarSessionLoading: true });
+
if (session.active) {
sessionLoadPoolRef.current.cancelPending();
onSessionLoadStateChangeRef.current?.(null);
- } else {
- let result;
- try {
- result = await sessionLoadPoolRef.current.request(
- loadKey,
- () => resumeSidebarSession(ws, session),
- );
- } catch (error) {
- if (sessionSelectionIntentRef.current?.sequence !== sequence) return;
- cancelSessionSelection({ cancelPending: false });
- toast({ kind: 'err', text: '恢复失败:' + (error?.message || '') });
- return;
- }
- if (result.status === 'superseded') return;
- if (sessionSelectionIntentRef.current?.sequence !== sequence) return;
- selectedTarget = sidebarSessionTarget(ws, session, result.value || {});
+ return;
}
- if (sessionSelectionIntentRef.current?.sequence !== sequence) return;
- if (selectedTarget.noWorkspace) {
- setActiveWorkspaceHash('');
- setWorkspaces((prev) => prev.map((item) => ({ ...item, active: false })));
+ let result;
+ try {
+ result = await sessionLoadPoolRef.current.request(
+ loadKey,
+ () => resumeSidebarSession(ws, session),
+ );
+ } catch (error) {
+ if (sessionSelectionIntentRef.current?.sequence !== sequence) return;
+ cancelSessionSelection({ cancelPending: false });
+ onSelect?.({
+ ...target,
+ active: false,
+ resumePending: false,
+ ...expertReferenceForSession(session),
+ }, { preserveSidebarSessionLoading: true });
+ toast({ kind: 'err', text: '恢复失败:' + (error?.message || '') });
+ return;
}
- markSessionRead({
- ...(selectedTarget.noWorkspace ? normalizeNoWorkspaceSession(session) : session),
- id: selectedTarget.sessionId,
- workspace_hash: selectedTarget.workspaceHash,
- cwd: selectedTarget.cwd,
- });
- onSessionLoadStateChangeRef.current?.(null);
+ if (result.status === 'superseded') return;
+ if (sessionSelectionIntentRef.current?.sequence !== sequence) return;
+ const resumedTarget = sidebarSessionTarget(ws, session, result.value || {});
onSelect?.({
- ...selectedTarget,
+ ...resumedTarget,
active: true,
+ resumePending: false,
...expertReferenceForSession(session),
- });
+ }, { preserveSidebarSessionLoading: true });
+ onSessionLoadStateChangeRef.current?.(null);
};
const onRename = async (hash, name) => {
@@ -3675,7 +3707,7 @@ export function Sidebar({
width: sessionDragGhost.width,
}}
>
-
+
{sessionDragGhost.title}
{sessionDragGhost.timeText}
diff --git a/web/src/i18n/catalogs/en-US.js b/web/src/i18n/catalogs/en-US.js
index fa0cf064..be7f4ef2 100644
--- a/web/src/i18n/catalogs/en-US.js
+++ b/web/src/i18n/catalogs/en-US.js
@@ -74,6 +74,7 @@ export const enUS = {
sidebarLoading: 'Loading “{{title}}”…',
sidebarLoadingFallback: 'Loading conversation…',
sidebarQueueHint: 'Only the latest click is kept and will open when a slot is free.',
+ pendingReply: 'Reply needed',
transcriptLoading: 'Loading conversation content…',
transcriptError: 'Conversation content failed to load. Switch away and retry.',
},
diff --git a/web/src/i18n/catalogs/zh-CN.js b/web/src/i18n/catalogs/zh-CN.js
index 2fb801cc..bb8285ec 100644
--- a/web/src/i18n/catalogs/zh-CN.js
+++ b/web/src/i18n/catalogs/zh-CN.js
@@ -74,6 +74,7 @@ export const zhCN = {
sidebarLoading: '正在加载“{{title}}”…',
sidebarLoadingFallback: '正在加载会话…',
sidebarQueueHint: '只保留最近一次点击,空闲后自动打开',
+ pendingReply: '待回复',
transcriptLoading: '正在读取会话内容…',
transcriptError: '会话内容加载失败,请切换后重试',
},
diff --git a/web/src/lib/expertComponentsArchitecture.test.js b/web/src/lib/expertComponentsArchitecture.test.js
index d2c24f63..3c7d1018 100644
--- a/web/src/lib/expertComponentsArchitecture.test.js
+++ b/web/src/lib/expertComponentsArchitecture.test.js
@@ -219,7 +219,7 @@ test('all real composers host the picker in place and opening prompts use atomic
assert.match(controls, /onClick=\{onRemoveExpert\}/);
assert.match(sidebar, /function expertReferenceForSession/);
- assert.equal((sidebar.match(/\.\.\.expertReferenceForSession\(session\)/g) || []).length, 2);
+ assert.equal((sidebar.match(/\.\.\.expertReferenceForSession\(session\)/g) || []).length, 4);
assert.match(app, /\s*权限请求\s*);
assert.match(row, /pendingPermission \? \(/);
assert.match(row, /: !editing && pendingQuestion \? \(/);
+ assert.match(row, /data-sidebar-pending-reply="true"/);
+ assert.match(row, /className="[^"]*bg-accent[^"]*font-normal[^"]*text-white"/);
+ assert.match(row, /\{tr\('sessionNavigation\.pendingReply'\)\}/);
+ assert.doesNotMatch(row, />\s*等待回复\s*);
assert.ok(
row.indexOf('pendingPermission ?') < row.indexOf('pendingQuestion ?'),
'permission pill must take precedence over the question pill',
@@ -41,6 +45,21 @@ run('SessionRow uses the AskUserQuestion slot with exact permission wording', ()
assert.match(row, /onSelect\?\.\(s\)/);
});
+run('running sessions use the four-dot breathing indicator', () => {
+ const sidebar = source('components/Sidebar.jsx');
+ const styles = source('styles/globals.css');
+ const indicator = between(sidebar, 'function SessionAttentionIndicator', 'function SessionHoverCard');
+ assert.match(indicator, /if \(attention !== 'in_progress' && attention !== 'unread'\) return null/);
+ assert.equal((indicator.match(/ace-session-loading-dot is-/g) || []).length, 4);
+ assert.match(indicator, /role="status"/);
+ assert.match(styles, /\.ace-session-loading-orbit\s*\{[\s\S]*animation: ace-session-loading-turn 6\.47s linear infinite/);
+ assert.match(styles, /\.ace-session-loading-dot\.is-top/);
+ assert.match(styles, /\.ace-session-loading-dot\.is-right/);
+ assert.match(styles, /\.ace-session-loading-dot\.is-bottom/);
+ assert.match(styles, /\.ace-session-loading-dot\.is-left/);
+ assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.ace-session-loading-orbit/);
+});
+
run('permission state reaches pinned, no-workspace, and workspace session rows', () => {
const sidebar = source('components/Sidebar.jsx');
const matches = sidebar.match(
diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js
index 88888b0d..bcd32074 100644
--- a/web/src/lib/sessionTranscript.js
+++ b/web/src/lib/sessionTranscript.js
@@ -1637,10 +1637,13 @@ export function loadTranscriptHistory(state, data = {}) {
}
export function canLiveMonitorSession(sessionRef, live = 'auto') {
- if (live === true) return true;
- if (live === false) return false;
const ref = normalizeSessionRef(sessionRef);
- if (!ref) return false;
+ // Optimistic navigation can render a disk-backed session before the daemon
+ // has finished registering its runtime entry. This safety boundary must win
+ // even when the caller generally enables live monitoring for writable chats.
+ if (ref?.resumePending === true) return false;
+ if (live === true) return true;
+ if (live === false || !ref) return false;
const status = ref.status || ref.attention_state || ref.read_state || '';
return !!(
ref.active ||
diff --git a/web/src/lib/sessionTranscript.test.js b/web/src/lib/sessionTranscript.test.js
index ed0dbc2d..045ff27f 100644
--- a/web/src/lib/sessionTranscript.test.js
+++ b/web/src/lib/sessionTranscript.test.js
@@ -2144,6 +2144,8 @@ run('live/static 判定区分 active running 与磁盘历史', () => {
assert.equal(canLiveMonitorSession({ id: 's1', active: true }), true);
assert.equal(canLiveMonitorSession({ id: 's1', status: 'running' }), true);
assert.equal(canLiveMonitorSession({ id: 's1', status: 'idle', active: false }), false);
+ assert.equal(canLiveMonitorSession({ id: 's1', active: true, resumePending: true }), false);
+ assert.equal(canLiveMonitorSession({ id: 's1', active: true, resumePending: true }, true), false);
assert.equal(canLiveMonitorSession({ id: 's1' }, true), true);
assert.equal(canLiveMonitorSession({ id: 's1', active: true }, false), false);
});
diff --git a/web/src/lib/sidebarSessionLoadingArchitecture.test.js b/web/src/lib/sidebarSessionLoadingArchitecture.test.js
index 37ce7d53..54b11eb3 100644
--- a/web/src/lib/sidebarSessionLoadingArchitecture.test.js
+++ b/web/src/lib/sidebarSessionLoadingArchitecture.test.js
@@ -40,28 +40,43 @@ run('侧栏先投影最新选择,再通过有界池恢复会话', () => {
assert.match(selection, /replaceSessionSelectionIntent\(intent\)/);
assert.match(selection, /sessionLoadPoolRef\.current\.request\(/);
assert.match(selection, /sessionLoadPoolRef\.current\.cancelPending\(\)/);
+ assert.ok(
+ selection.indexOf('onSelect?.({') < selection.indexOf('await sessionLoadPoolRef.current.request('),
+ 'main content must switch before the slow runtime resume settles',
+ );
+ assert.match(selection, /onSelect\?\.\(\{[\s\S]*active: session\.active === true/);
+ assert.match(selection, /resumePending: session\.active !== true/);
+ assert.match(selection, /preserveSidebarSessionLoading: true/);
+ assert.match(source('lib/sessionTranscript.js'), /resumePending === true/);
});
-run('只有仍为最新点击的恢复结果可以提交导航', () => {
+run('最新点击立即提交导航且旧恢复结果不能覆盖它', () => {
const sidebar = source('components/Sidebar.jsx');
const selection = between(sidebar, 'const selectSession', 'const onRename');
const latestGuards = selection.match(
/sessionSelectionIntentRef\.current\?\.sequence !== sequence/g,
) || [];
- assert.ok(latestGuards.length >= 3, 'error, result and commit paths must all reject stale clicks');
- assert.ok(
- selection.indexOf('sessionSelectionIntentRef.current?.sequence !== sequence')
- < selection.indexOf('onSelect?.({'),
- 'latest-only guard must run before navigation commit',
+ assert.ok(latestGuards.length >= 2, 'error and completion paths must reject stale resumes');
+ assert.equal(
+ (selection.match(/onSelect\?\.\(\{/g) || []).length,
+ 3,
+ 'the session is projected immediately, cleared on failure, and promoted on success',
);
- assert.match(selection, /if \(result\.status === 'superseded'\) return/);
+ const firstCommit = selection.indexOf('onSelect?.({');
+ const resumeRequest = selection.indexOf('await sessionLoadPoolRef.current.request(');
+ const liveCommit = selection.lastIndexOf('onSelect?.({');
+ assert.ok(firstCommit < resumeRequest && resumeRequest < liveCommit,
+ 'the latest click must project before resume and promote after it');
});
-run('外部导航、归档和卸载会清理侧栏 pending 意图', () => {
+run('乐观导航保留恢复意图,外部导航、归档和卸载会清理它', () => {
const sidebar = source('components/Sidebar.jsx');
const app = source('App.jsx');
+ assert.match(sidebar, /revealedIntent && activeRef\?\.resumePending !== true/);
+ assert.match(sidebar, /!revealedIntent && activeNavigationIdentity !== intent\.baselineNavigationIdentity/);
+ assert.match(app, /if \(!options\.preserveSidebarSessionLoading\) resetSidebarSessionLoading\(\)/);
assert.match(sidebar, /sessionLoadResetSequence = 0/);
assert.match(sidebar, /cancelSessionSelection\(\);\s*\}, \[cancelSessionSelection, sessionLoadResetSequence\]\)/);
assert.match(sidebar, /sessionSelectionIntentRef\.current\?\.loadKey === loadKey[\s\S]*cancelSessionSelection\(\)/);
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index d99d9fd3..781871fd 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -2531,6 +2531,23 @@ body {
50% { opacity: 1; transform: scale(1); }
}
@keyframes ace-spin { to { transform: rotate(360deg); } }
+@keyframes ace-session-loading-turn { to { transform: rotate(360deg); } }
+@keyframes ace-session-loading-dot-top {
+ 0%, 100% { transform: translateY(-5px); }
+ 36%, 72% { transform: translateY(-2.35px); }
+}
+@keyframes ace-session-loading-dot-right {
+ 0%, 100% { transform: translateX(5px); }
+ 36%, 72% { transform: translateX(2.35px); }
+}
+@keyframes ace-session-loading-dot-bottom {
+ 0%, 100% { transform: translateY(5px); }
+ 36%, 72% { transform: translateY(2.35px); }
+}
+@keyframes ace-session-loading-dot-left {
+ 0%, 100% { transform: translateX(-5px); }
+ 36%, 72% { transform: translateX(-2.35px); }
+}
.ace-spinner {
display: inline-block; width: 1em; height: 1em;
border: 2px solid rgba(var(--ace-fg-mute-rgb), 0.40);
@@ -2544,14 +2561,45 @@ body {
border-top-color: rgba(var(--ace-fg-mute-rgb), 0.40);
}
.ace-session-loading {
+ position: relative;
display: inline-block;
- width: 10px;
- height: 10px;
- border: 1.5px solid rgba(var(--ace-accent-rgb), 0.24);
- border-top-color: var(--ace-accent);
+ width: 16px;
+ height: 16px;
+ color: var(--ace-accent);
+}
+.ace-session-loading-orbit {
+ position: absolute;
+ inset: 0;
+ animation: ace-session-loading-turn 6.47s linear infinite;
+}
+.ace-session-loading-dot {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 5.5px;
+ height: 5.5px;
+ margin: -2.75px 0 0 -2.75px;
border-radius: 9999px;
- animation: ace-spin .75s linear infinite;
- box-shadow: 0 0 4px rgba(var(--ace-accent-rgb), 0.35);
+ background: currentColor;
+ transform-origin: center;
+ animation-duration: 2.156s;
+ animation-timing-function: cubic-bezier(.65, 0, .35, 1);
+ animation-iteration-count: infinite;
+}
+.ace-session-loading-dot.is-top { animation-name: ace-session-loading-dot-top; }
+.ace-session-loading-dot.is-right { animation-name: ace-session-loading-dot-right; }
+.ace-session-loading-dot.is-bottom { animation-name: ace-session-loading-dot-bottom; }
+.ace-session-loading-dot.is-left { animation-name: ace-session-loading-dot-left; }
+
+@media (prefers-reduced-motion: reduce) {
+ .ace-session-loading-orbit,
+ .ace-session-loading-dot {
+ animation: none;
+ }
+ .ace-session-loading-dot.is-top { transform: translateY(-3.5px); }
+ .ace-session-loading-dot.is-right { transform: translateX(3.5px); }
+ .ace-session-loading-dot.is-bottom { transform: translateY(3.5px); }
+ .ace-session-loading-dot.is-left { transform: translateX(-3.5px); }
}
/* Upgrade progress is deliberately quicker than the 350ms status polling interval,