Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
106 changes: 69 additions & 37 deletions web/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' ? (
<span className="ace-session-loading shrink-0" title={meta.label} aria-label={meta.label} />
<span
className="ace-session-loading shrink-0"
title={meta.label}
role="status"
aria-label={meta.label}
>
<span className="ace-session-loading-orbit" aria-hidden="true">
<span className="ace-session-loading-dot is-top" />
<span className="ace-session-loading-dot is-right" />
<span className="ace-session-loading-dot is-bottom" />
<span className="ace-session-loading-dot is-left" />
</span>
</span>
) : (
<span className={clsx('w-2 h-2 rounded-full shrink-0 box-border', meta.dot)} title={meta.label} />
);
Expand Down Expand Up @@ -1275,10 +1289,11 @@ function SessionRow({
</span>
) : !editing && pendingQuestion ? (
<span
className="ace-sidebar-row-idle-slot shrink-0 rounded-full border border-ok-border bg-ok-bg px-2 py-[1px] text-[11px] font-medium leading-[18px] text-ok"
data-sidebar-pending-reply="true"
className="ace-sidebar-row-idle-slot shrink-0 rounded-full border border-accent bg-accent px-2 py-[1px] text-[11px] font-normal leading-[18px] text-white"
title="等待用户回复 AskUserQuestion"
>
等待回复
{tr('sessionNavigation.pendingReply')}
</span>
) : null}
{showSessionTime && !editing && !pendingPermission && !pendingQuestion && (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -3675,7 +3707,7 @@ export function Sidebar({
width: sessionDragGhost.width,
}}
>
<span className="w-2 h-2 rounded-full shrink-0 box-border border border-fg-mute/55" />
<span className="w-2 shrink-0" aria-hidden="true" />
<span className="flex-1 min-w-0 truncate">{sessionDragGhost.title}</span>
<span className="text-[10px] text-fg-mute shrink-0">{sessionDragGhost.timeText}</span>
</div>
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/catalogs/en-US.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/catalogs/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const zhCN = {
sidebarLoading: '正在加载“{{title}}”…',
sidebarLoadingFallback: '正在加载会话…',
sidebarQueueHint: '只保留最近一次点击,空闲后自动打开',
pendingReply: '待回复',
transcriptLoading: '正在读取会话内容…',
transcriptError: '会话内容加载失败,请切换后重试',
},
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/expertComponentsArchitecture.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<ExpertComponentsPage/);
assert.match(app, /recentExpertIds=\{recentExpertIds\}/);
Expand Down
19 changes: 19 additions & 0 deletions web/src/lib/permissionSidebarArchitecture.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,32 @@ run('SessionRow uses the AskUserQuestion slot with exact permission wording', ()
assert.match(row, />\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',
);
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(
Expand Down
9 changes: 6 additions & 3 deletions web/src/lib/sessionTranscript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/sessionTranscript.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
31 changes: 23 additions & 8 deletions web/src/lib/sidebarSessionLoadingArchitecture.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\(\)/);
Expand Down
60 changes: 54 additions & 6 deletions web/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Loading