fix(web-shell): improve session restore and loading feedback (#6220)

* fix(web-shell): avoid smooth scroll on session restore

* fix(web-shell): show skeleton during session load

* fix(web-shell): tighten session restore guards

* fix(web-shell): address session restore review issues

* fix(web-shell): clear session loading on load failure

* test(web-shell): cover session restore review cases

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-03 16:44:12 +08:00 committed by GitHub
parent e3076e2990
commit b1ec04f4bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 844 additions and 154 deletions

View file

@ -2028,14 +2028,11 @@ export function App({
const loadSidebarSession = useCallback(
async (sessionId: string) => {
setSidebarSwitchingSessionId(sessionId);
// Close the drawer before awaiting the load so it doesn't linger over the
// old transcript while the new session streams in, matching the other
// session-switch paths (/resume, ResumeDialog).
// Close the drawer before awaiting the load; the transcript clears
// immediately and shows its loading skeleton for the selected session.
closeMobileDrawer();
try {
await sessionActions.loadSession(sessionId, {
deferTranscriptReset: true,
});
await sessionActions.loadSession(sessionId);
} catch (error) {
setSidebarSwitchingSessionId((current) =>
current === sessionId ? null : current,
@ -2050,11 +2047,17 @@ export function App({
if (
sidebarSwitchingSessionId !== null &&
connection.sessionId === sidebarSwitchingSessionId &&
!connection.loadingTranscript &&
!connection.catchingUp
) {
setSidebarSwitchingSessionId(null);
}
}, [connection.catchingUp, connection.sessionId, sidebarSwitchingSessionId]);
}, [
connection.catchingUp,
connection.loadingTranscript,
connection.sessionId,
sidebarSwitchingSessionId,
]);
const openTasksPanel = useCallback(() => {
if (!requireActiveSessionForLocalCommand()) return;
@ -2200,6 +2203,10 @@ export function App({
const handleSubmit = useCallback(
(text: string, images?: PromptImage[]) => {
if (connectionRef.current.loadingTranscript) {
pushToast('warning', t('editor.sessionLoading'));
return false;
}
if (
shouldBlockComposerSubmit({
connectionStatus: connectionRef.current.status,
@ -3800,6 +3807,7 @@ export function App({
messages={displayMessages}
pendingApproval={pendingToolApproval}
onShowContextDetail={handleShowContextDetail}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}

View file

@ -128,6 +128,8 @@ function mount(
ref?: RefObject<MessageListHandle | null>,
opts: {
hideSessionTimeline?: boolean;
loadingTranscript?: boolean;
catchingUp?: boolean;
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
@ -143,6 +145,8 @@ function mount(
messages={messages}
pendingApproval={null}
hideSessionTimeline={opts.hideSessionTimeline}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
@ -154,6 +158,35 @@ function mount(
return container;
}
function renderInto(
root: Root,
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: {
loadingTranscript?: boolean;
catchingUp?: boolean;
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
) {
act(() => {
root.render(
<I18nProvider language="en">
<MessageList
ref={ref}
messages={messages}
pendingApproval={null}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
);
});
}
const has = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="msg-${id}"]`) !== null;
const assistantActions = (c: HTMLElement, id: string) =>
@ -412,7 +445,7 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(isCollapsed(c, 'g1')).toBe(false);
});
it('smooth-scrolls the page when a new chat prompt appears', () => {
it('smooth-scrolls the page when a new chat prompt appears', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
@ -427,7 +460,14 @@ describe('MessageList — turn collapse (DOM)', () => {
value: scrollTo,
});
mount([userMsg('u1')]);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [userMsg('u1'), asstMsg('a1'), userMsg('u2')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
@ -435,6 +475,229 @@ describe('MessageList — turn collapse (DOM)', () => {
});
});
it('does not smooth-scroll when initial history already contains a user prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
mount([userMsg('u1'), asstMsg('a1')]);
expect(scrollTo).not.toHaveBeenCalled();
});
it('shows a transcript skeleton while loading transcript', () => {
const c = mount([], undefined, { loadingTranscript: true });
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'Session is still loading. Try again in a moment.',
);
});
it('shows the transcript skeleton while loading transcript with existing messages', () => {
const c = mount([userMsg('u1')], undefined, {
loadingTranscript: true,
});
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
});
it('does not show the transcript skeleton outside transcript loading', () => {
const idle = mount([]);
expect(
idle.querySelector('[data-testid="message-list-loading-skeleton"]'),
).toBeNull();
});
it('does not smooth-scroll when existing session history loads after an empty render', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('smooth-scrolls the first new prompt after an empty render', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll restored history that ends with a user prompt', async () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [], undefined, { loadingTranscript: true });
renderInto(root, [userMsg('u1')], undefined, {
loadingTranscript: false,
});
await nextFrame();
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll when a user prompt is already followed by an assistant row', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [
userMsg('u1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
]);
await nextFrame();
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('snaps to bottom without smooth scrolling when catch-up completes', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const messages = [userMsg('u1'), asstMsg('a1')];
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, messages, undefined, { catchingUp: true });
expect(scrollTop).toBe(0);
renderInto(root, messages, undefined, { catchingUp: false });
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('does not treat a user_shell row as a new chat prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {

View file

@ -30,6 +30,104 @@
transition: width 320ms ease;
}
.loadingSkeleton {
display: flex;
flex-direction: column;
gap: 28px;
padding: 18px 0 36px;
pointer-events: none;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.loadingSkeletonUserRow {
display: flex;
justify-content: flex-end;
width: 100%;
}
.loadingSkeletonAssistantRow {
display: flex;
justify-content: flex-start;
width: 100%;
}
.loadingSkeletonUserBubble,
.loadingSkeletonUserBubbleCompact {
display: flex;
flex-direction: column;
gap: 9px;
width: min(62%, 520px);
padding: 12px 14px;
border-radius: 6px;
background: var(--accent);
}
.loadingSkeletonUserBubbleCompact {
width: min(46%, 360px);
}
.loadingSkeletonAssistantBlock {
display: flex;
flex-direction: column;
gap: 10px;
width: min(72%, 640px);
padding: 3px 0;
}
.loadingSkeletonLineWide,
.loadingSkeletonLineMedium,
.loadingSkeletonLineShort,
.loadingSkeletonLineNarrow {
display: block;
height: 16px;
border-radius: 4px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted-foreground) 10%, transparent) 25%,
color-mix(in srgb, var(--muted-foreground) 24%, transparent) 37%,
color-mix(in srgb, var(--muted-foreground) 10%, transparent) 63%
);
background-size: 400% 100%;
animation: loadingSkeletonShimmer 1.4s ease infinite;
}
.loadingSkeletonLineWide {
width: 100%;
}
.loadingSkeletonLineMedium {
width: 72%;
}
.loadingSkeletonLineShort {
width: 48%;
}
.loadingSkeletonLineNarrow {
width: 58%;
}
@keyframes loadingSkeletonShimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.rowFlash {
border-radius: 8px;
animation: row-flash 1.6s ease-out;

View file

@ -36,6 +36,7 @@ interface MessageListProps {
pendingApproval: PermissionRequest | null;
/** Run /context detail, exactly like typing it (context-usage panels). */
onShowContextDetail?: () => void;
loadingTranscript?: boolean;
catchingUp?: boolean;
/**
* True while the agent is still answering. The newest turn then stays
@ -70,6 +71,10 @@ function getLastUserMessageId(messages: Message[]): string | null {
return null;
}
function getLastMessage(messages: Message[]): Message | undefined {
return messages[messages.length - 1];
}
function getLastTurnStartMessageId(messages: Message[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
@ -1652,12 +1657,53 @@ function joinClassNames(
const EMPTY_SESSION_TIMELINE_ENTRIES: SessionTimelineEntry[] = [];
function LoadingTranscriptSkeleton({ label }: { label: string }) {
return (
<>
<div role="status" aria-live="polite" className={styles.srOnly}>
{label}
</div>
<div
className={styles.loadingSkeleton}
data-testid="message-list-loading-skeleton"
aria-hidden="true"
>
<div className={styles.loadingSkeletonUserRow}>
<div className={styles.loadingSkeletonUserBubble}>
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineShort} />
</div>
</div>
<div className={styles.loadingSkeletonAssistantRow}>
<div className={styles.loadingSkeletonAssistantBlock}>
<span className={styles.loadingSkeletonLineMedium} />
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineNarrow} />
</div>
</div>
<div className={styles.loadingSkeletonUserRow}>
<div className={styles.loadingSkeletonUserBubbleCompact}>
<span className={styles.loadingSkeletonLineMedium} />
</div>
</div>
<div className={styles.loadingSkeletonAssistantRow}>
<div className={styles.loadingSkeletonAssistantBlock}>
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineMedium} />
</div>
</div>
</div>
</>
);
}
export const MessageList = memo(
forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
messages,
pendingApproval,
onShowContextDetail,
loadingTranscript,
catchingUp,
isResponding = false,
activeTurnStartedAt,
@ -1676,6 +1722,7 @@ export const MessageList = memo(
},
ref,
) {
const { t } = useI18n();
const compactMode = useContext(CompactModeContext);
const mergedMessages = useMemo(
() =>
@ -1768,7 +1815,10 @@ export const MessageList = memo(
const scrollCooldownCount = useRef(0);
const sessionTimelineFrame = useRef<number | null>(null);
const lastReportedCanScrollToBottom = useRef<boolean | null>(null);
const didTrackLastUserMsgRef = useRef(false);
const prevLastUserMsgId = useRef<string | null>(null);
const pendingNewUserSmoothScroll = useRef(false);
const prevLoadingTranscript = useRef(loadingTranscript);
const prevActiveExecutionKey = useRef<string | null>(null);
const prevCatchingUp: MutableRefObject<boolean | undefined> =
useRef(catchingUp);
@ -1906,6 +1956,7 @@ export const MessageList = memo(
// ─────────────────────────────────────────────────────────────────────
const hasTailContent = tailContent !== undefined && tailContent !== null;
const showLoadingSkeleton = Boolean(loadingTranscript);
const hasHeader = !!welcomeHeader;
const headerOffset = hasHeader ? 1 : 0;
const tailContentIndex = headerOffset + visibleItems.length;
@ -2440,27 +2491,45 @@ export const MessageList = memo(
// Rule 4: new user message → force follow on so the model's reply
// scrolls into view as it streams in.
useEffect(() => {
useLayoutEffect(() => {
const lastId = getLastUserMessageId(messages);
if (catchingUp) {
if (catchingUp || loadingTranscript || prevLoadingTranscript.current) {
prevLastUserMsgId.current = lastId;
didTrackLastUserMsgRef.current = true;
pendingNewUserSmoothScroll.current = false;
prevLoadingTranscript.current = loadingTranscript;
return;
}
if (lastId && lastId !== prevLastUserMsgId.current) {
prevLoadingTranscript.current = loadingTranscript;
if (!didTrackLastUserMsgRef.current) {
prevLastUserMsgId.current = lastId;
didTrackLastUserMsgRef.current = true;
pendingNewUserSmoothScroll.current = false;
return;
}
const lastMessage = getLastMessage(messages);
if (
lastId &&
lastMessage?.role === 'user' &&
lastId !== prevLastUserMsgId.current
) {
setShouldFollow(true);
// A new prompt supersedes any pending "Show in transcript" scroll.
pendingScrollRef.current = null;
pendingNewUserSmoothScroll.current = true;
} else {
pendingNewUserSmoothScroll.current = false;
}
prevLastUserMsgId.current = lastId;
}, [messages, catchingUp, setShouldFollow]);
}, [messages, catchingUp, loadingTranscript, setShouldFollow]);
// Rule 5: session restore — when catchingUp flips from true → falsy,
// replay just finished. Scroll to bottom once so the user sees the
// latest content without the viewport fighting the replay.
useEffect(() => {
useLayoutEffect(() => {
if (prevCatchingUp.current && !catchingUp) {
setShouldFollow(true);
requestAnimationFrame(() => scrollToBottom());
scrollToBottom('auto');
}
prevCatchingUp.current = catchingUp;
}, [catchingUp, scrollToBottom, setShouldFollow]);
@ -2639,13 +2708,14 @@ export const MessageList = memo(
// and is a no-op when there's no overflow.
useLayoutEffect(() => {
if (catchingUp) return;
if (scrollCooldown.current) return;
if (pendingFollowRecheck.current) return;
if (shouldFollow.current) {
const lastId = getLastUserMessageId(messages);
const isNewUserMessage =
lastId !== null && lastId !== prevLastUserMsgId.current;
const isNewUserMessage = pendingNewUserSmoothScroll.current;
if (scrollCooldown.current && !isNewUserMessage) return;
// Preserve the new-prompt scroll even if a previous disclosure resize is
// still settling; it targets the latest virtualizer size from this render.
if (pendingFollowRecheck.current && !isNewUserMessage) return;
if (shouldFollow.current || isNewUserMessage) {
scrollToBottom(isNewUserMessage ? 'smooth' : 'auto');
pendingNewUserSmoothScroll.current = false;
}
}, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]);
@ -2659,6 +2729,9 @@ export const MessageList = memo(
className={styles.list}
onClickCapture={handleDisclosureClickCapture}
>
{showLoadingSkeleton && (
<LoadingTranscriptSkeleton label={t('editor.sessionLoading')} />
)}
<SessionTimeline
entries={sessionTimelineEntries}
currentTurnId={currentTimelineTurnId}

View file

@ -384,6 +384,7 @@ const EN: Messages = {
'editor.send': 'Send message',
'editor.connectionDisconnected':
'Connection interrupted. Please try again after it reconnects.',
'editor.sessionLoading': 'Session is still loading. Try again in a moment.',
'editor.processing': 'Processing. New messages will be queued.',
'editor.searchHint': 'ctrl+r next · tab accept · enter send · esc cancel',
'editor.searchLabel': 'reverse-i-search:',
@ -1599,6 +1600,7 @@ const ZH: Messages = {
'editor.shellPlaceholder': '请输入终端命令',
'editor.send': '发送消息',
'editor.connectionDisconnected': '连接已中断,请在恢复后重试。',
'editor.sessionLoading': '会话正在加载,请稍后再发送。',
'editor.processing': '处理中。新消息会进入队列。',
'editor.searchHint': 'ctrl+r 下一条 · tab 采纳 · enter 发送 · esc 取消',
'editor.searchLabel': '历史搜索:',

View file

@ -4851,6 +4851,9 @@ describe('DaemonSessionProvider', () => {
await act(async () => {
await flushPromises();
});
sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce(async () => {
throw createAbortError();
});
await act(async () => {
const loadPromise = requireActions(actions).loadSession('session-b');
@ -4911,78 +4914,35 @@ describe('DaemonSessionProvider', () => {
]);
});
it('keeps transcript until replay for deferred session switches', async () => {
it('loads controlled sessionId changes', async () => {
const nextSession = createDeferred<MockSession>();
const currentSession = createMockSession({
replaySnapshot: createTextReplaySnapshot('old transcript'),
});
sdkMocks.sessions.push(currentSession);
let actions: DaemonSessionActions | undefined;
sdkMocks.sessions.push(
createMockSession({
sessionId: 'session-a',
replaySnapshot: createTextReplaySnapshot('old transcript'),
}),
);
let blocks: readonly DaemonTranscriptBlock[] = [];
let connection: DaemonConnectionState | undefined;
function Harness() {
actions = useDaemonActions();
blocks = useDaemonTranscriptBlocks();
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, { autoConnect: true });
await act(async () => {
await flushPromises();
});
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'old transcript' },
]);
sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce(
async () => nextSession.promise,
);
const loadPromise = requireActions(actions)
.loadSession('session-b', { deferTranscriptReset: true })
.catch(() => undefined);
await act(async () => {
await flushPromises();
});
expect(connection?.catchingUp).toBe(true);
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'old transcript' },
]);
nextSession.resolve(
createMockSession({
sessionId: 'session-b',
replaySnapshot: createTextReplaySnapshot('new transcript'),
}),
);
await act(async () => {
await loadPromise;
await flushPromises();
});
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'new transcript' },
]);
});
it('loads controlled sessionId changes', async () => {
sdkMocks.sessions.push(
createMockSession({ sessionId: 'session-a' }),
createMockSession({ sessionId: 'session-b' }),
);
let connection: DaemonConnectionState | undefined;
function Harness() {
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
});
expect(connection).toMatchObject({ sessionId: 'session-a' });
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'old transcript' },
]);
sdkMocks.MockDaemonSessionClient.load.mockClear();
sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce(
async () => nextSession.promise,
);
act(() => {
root?.render(
@ -4995,6 +4955,21 @@ describe('DaemonSessionProvider', () => {
</DaemonSessionProvider>,
);
});
await act(async () => {
await flushPromises();
});
expect(connection).toMatchObject({
sessionId: 'session-b',
loadingTranscript: true,
});
expect(blocks).toEqual([]);
nextSession.resolve(
createMockSession({
sessionId: 'session-b',
replaySnapshot: createTextReplaySnapshot('new transcript'),
}),
);
await act(async () => {
await wait(5);
await flushPromises();
@ -5007,6 +4982,164 @@ describe('DaemonSessionProvider', () => {
expect.any(String),
);
expect(connection).toMatchObject({ sessionId: 'session-b' });
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'new transcript' },
]);
});
it('clears transcript loading after replay before metadata finishes', async () => {
const providers = createDeferred<unknown>();
const commands =
createDeferred<Awaited<ReturnType<MockSession['supportedCommands']>>>();
const context =
createDeferred<Awaited<ReturnType<MockSession['context']>>>();
sdkMocks.workspaceProviders.mockReturnValueOnce(providers.promise);
sdkMocks.sessions.push(
createMockSession({
sessionId: 'session-a',
replaySnapshot: createTextReplaySnapshot('restored transcript'),
supportedCommands: vi.fn(() => commands.promise),
context: vi.fn(() => context.promise),
}),
);
let blocks: readonly DaemonTranscriptBlock[] = [];
let connection: DaemonConnectionState | undefined;
function Harness() {
blocks = useDaemonTranscriptBlocks();
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
});
await act(async () => {
await flushPromises();
});
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'restored transcript' },
]);
expect(connection).toMatchObject({
status: 'connected',
sessionId: 'session-a',
loadingTranscript: undefined,
});
providers.resolve({
v: 1,
workspaceCwd: '/mock-workspace',
initialized: true,
providers: [],
});
commands.resolve({
v: 1,
sessionId: 'session-a',
availableCommands: [],
availableSkills: [],
});
context.resolve({
v: 1,
sessionId: 'session-a',
workspaceCwd: '/mock-workspace',
state: {},
});
await act(async () => {
await flushPromises();
});
});
it('ignores stale metadata after switching to another session', async () => {
const providersA = createDeferred<unknown>();
const commandsA =
createDeferred<Awaited<ReturnType<MockSession['supportedCommands']>>>();
const contextA =
createDeferred<Awaited<ReturnType<MockSession['context']>>>();
sdkMocks.workspaceProviders.mockReturnValueOnce(providersA.promise);
sdkMocks.sessions.push(
createMockSession({
sessionId: 'session-a',
replaySnapshot: createTextReplaySnapshot('session a transcript'),
supportedCommands: vi.fn(() => commandsA.promise),
context: vi.fn(() => contextA.promise),
}),
createMockSession({
sessionId: 'session-b',
replaySnapshot: createTextReplaySnapshot('session b transcript'),
}),
);
let blocks: readonly DaemonTranscriptBlock[] = [];
let connection: DaemonConnectionState | undefined;
function Harness() {
blocks = useDaemonTranscriptBlocks();
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
});
await act(async () => {
await flushPromises();
});
expect(connection).toMatchObject({ sessionId: 'session-a' });
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'session a transcript' },
]);
act(() => {
root?.render(
<DaemonSessionProvider
baseUrl="http://127.0.0.1:4170"
autoConnect={true}
sessionId="session-b"
>
<Harness />
</DaemonSessionProvider>,
);
});
await act(async () => {
await flushPromises();
});
expect(connection).toMatchObject({
sessionId: 'session-b',
loadingTranscript: undefined,
});
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'session b transcript' },
]);
providersA.resolve({
v: 1,
workspaceCwd: '/mock-workspace',
initialized: true,
providers: [],
});
commandsA.resolve({
v: 1,
sessionId: 'session-a',
availableCommands: [],
availableSkills: [],
});
contextA.resolve({
v: 1,
sessionId: 'session-a',
workspaceCwd: '/mock-workspace',
state: { models: { currentModel: 'stale-model' } },
});
await act(async () => {
await flushPromises();
});
expect(connection).toMatchObject({ sessionId: 'session-b' });
expect(connection?.currentModel).not.toBe('stale-model');
expect(blocks).toMatchObject([
{ kind: 'assistant', text: 'session b transcript' },
]);
});
it('loads controlled sessionId on mount without creating a session', async () => {
@ -5035,6 +5168,35 @@ describe('DaemonSessionProvider', () => {
expect(connection).toMatchObject({ sessionId: 'session-a' });
});
it('marks controlled sessionId load as loading transcript before load returns', async () => {
const pendingSession = createDeferred<MockSession>();
sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce(
async () => pendingSession.promise,
);
let connection: DaemonConnectionState | undefined;
function Harness() {
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
});
expect(connection).toMatchObject({
status: 'connecting',
sessionId: 'session-a',
loadingTranscript: true,
});
pendingSession.resolve(createMockSession({ sessionId: 'session-a' }));
await act(async () => {
await flushPromises();
});
});
it('does not create a session when sessionId is undefined', async () => {
let connection: DaemonConnectionState | undefined;

View file

@ -484,6 +484,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const requestClientId = clientId
? clientIdRef.current
: getStableClientId(undefined, targetSessionId);
if (targetSessionId) {
setConnection((current) => ({
...current,
sessionId: targetSessionId,
error: undefined,
loadingTranscript: true,
}));
}
const nextSession = restoreSessionId
? await restoreMethod(
client,
@ -715,6 +723,38 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
}
setConnection((c) => ({ ...c, catchingUp: undefined }));
}
setConnection((current) => ({
...current,
status: 'connected',
sessionId: activeSession.sessionId,
...(activeSession.clientId
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
displayName:
getSessionDisplayName(activeSession.state) ??
(current.sessionId === activeSession.sessionId
? current.displayName
: undefined),
tokenUsage:
replayTokenUsage !== undefined
? replayTokenUsage
: current.sessionId === activeSession.sessionId
? current.tokenUsage
: undefined,
tokenCount:
replayTokenCount !== undefined
? replayTokenCount
: current.sessionId === activeSession.sessionId
? (current.tokenCount ?? 0)
: 0,
loadingTranscript: undefined,
catchingUp: replayInjected
? current.catchingUp
: isSameSessionReconnect ||
activeSession.lastEventId != null ||
undefined,
}));
if (pendingLoadToResolve) {
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoadToResolve.timeout);
@ -788,58 +828,42 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const currentMode =
getCurrentMode(context) ?? providerModelStatus.currentMode;
setConnection((current) => ({
status: 'connected',
sessionId: activeSession.sessionId,
// Surface the bound client id so consumers can recognize their own
// originator-stamped frames (e.g. the web-shell's mid-turn dedupe).
...(activeSession.clientId
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
commands: commands.length > 0 ? commands : current.commands,
skills: skills.length > 0 ? skills : current.skills,
models: sessionModels.length > 0 ? sessionModels : current.models,
currentModel: sessionCurrentModel ?? current.currentModel,
currentMode: currentMode ?? current.currentMode,
displayName:
getSessionDisplayName(activeSession.state) ??
(current.sessionId === activeSession.sessionId
? current.displayName
: undefined),
tokenUsage:
// Keep token usage in sync with tokenCount: replay usage
// supersedes in-memory state, same-session reconnect keeps it,
// and a different session without replay usage starts empty.
replayTokenUsage !== undefined
? replayTokenUsage
: current.sessionId === activeSession.sessionId
? current.tokenUsage
: undefined,
tokenCount:
// A freshly loaded snapshot covers everything up to the SSE
// resume point, so its usage supersedes the in-memory count;
// without one (or with a usage-less replay) keep the
// same-session value and start anything else at 0.
replayTokenCount !== undefined
? replayTokenCount
: current.sessionId === activeSession.sessionId
? (current.tokenCount ?? 0)
: 0,
contextWindow: sessionContextWindow ?? current.contextWindow,
providers: providers ?? current.providers,
supportedCommands: supportedCommands ?? current.supportedCommands,
context: context ?? current.context,
capabilities: capabilities ?? current.capabilities,
catchingUp:
// Replay already injected above — keep the cleared flag rather
// than re-arming it (nothing before SSE would clear it again).
replayInjected
? current.catchingUp
: isSameSessionReconnect ||
activeSession.lastEventId != null ||
undefined,
}));
setConnection((current) => {
if (current.sessionId !== activeSession.sessionId) return current;
return {
...current,
status: 'connected',
sessionId: activeSession.sessionId,
// Surface the bound client id so consumers can recognize their own
// originator-stamped frames (e.g. the web-shell's mid-turn dedupe).
...(activeSession.clientId
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
commands: commands.length > 0 ? commands : current.commands,
skills: skills.length > 0 ? skills : current.skills,
models: sessionModels.length > 0 ? sessionModels : current.models,
currentModel: sessionCurrentModel ?? current.currentModel,
currentMode: currentMode ?? current.currentMode,
displayName:
getSessionDisplayName(activeSession.state) ??
current.displayName,
contextWindow: sessionContextWindow ?? current.contextWindow,
providers: providers ?? current.providers,
supportedCommands: supportedCommands ?? current.supportedCommands,
context: context ?? current.context,
capabilities: capabilities ?? current.capabilities,
loadingTranscript: undefined,
catchingUp:
// Replay already injected above — keep the cleared flag rather
// than re-arming it (nothing before SSE would clear it again).
replayInjected
? current.catchingUp
: isSameSessionReconnect ||
activeSession.lastEventId != null ||
undefined,
};
});
if (loadWarningTexts.length > 0) {
store.dispatch(
loadWarningTexts.map((text) => ({
@ -1234,6 +1258,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
status: 'disconnected',
sessionId: undefined,
error: message,
loadingTranscript: undefined,
catchingUp: undefined,
}));
return;
@ -1263,6 +1288,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
...current,
status: 'disconnected',
error: message,
loadingTranscript: undefined,
}));
}
@ -1271,6 +1297,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
setConnection((current) => ({
...current,
status: 'disconnected',
loadingTranscript: undefined,
catchingUp: undefined,
}));
return;
@ -1470,7 +1497,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
if (sessionId === currentSessionId) return;
const request = sessionId
? actions.loadSession(sessionId, { deferTranscriptReset: true })
? actions.loadSession(sessionId)
: currentSessionId
? actions.clearSession()
: undefined;

View file

@ -25,6 +25,7 @@ describe('getConnectionAfterSessionClear', () => {
skills: ['old-skill'],
supportedCommands: supportedCommandsStatus('session-a'),
context: contextStatus('session-a'),
loadingTranscript: true,
catchingUp: true,
error: 'old error',
} as DaemonConnectionState,
@ -34,6 +35,7 @@ describe('getConnectionAfterSessionClear', () => {
expect(next).toMatchObject({
status: 'connected',
workspaceCwd: '/workspace',
loadingTranscript: undefined,
catchingUp: undefined,
error: undefined,
});
@ -60,6 +62,7 @@ describe('getConnectionAfterSessionClear', () => {
skills: ['new-skill'],
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
context: contextStatus('session-b'),
loadingTranscript: true,
catchingUp: true,
error: 'old error',
} as DaemonConnectionState,
@ -77,6 +80,7 @@ describe('getConnectionAfterSessionClear', () => {
skills: ['new-skill'],
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
context: contextStatus('session-b'),
loadingTranscript: undefined,
catchingUp: undefined,
error: undefined,
});
@ -140,6 +144,56 @@ describe('createDaemonSessionActions', () => {
expect(getConnection()).not.toHaveProperty('sessionId');
});
it('clears the active session while a session switch is loading', async () => {
const existingSession = createMockSession('session-a');
const { actions, getConnection, pendingSessionLoadRef, sessionRef } =
createActionsHarness({
connection: { status: 'connected', sessionId: 'session-a' },
session: existingSession,
});
void actions.loadSession('session-b').catch(() => undefined);
expect(existingSession.detach).toHaveBeenCalledOnce();
expect(sessionRef.current).toBeUndefined();
expect(getConnection()).toMatchObject({
status: 'connecting',
sessionId: 'session-b',
loadingTranscript: true,
catchingUp: undefined,
});
expect(pendingSessionLoadRef.current?.sessionId).toBe('session-b');
});
it('clears transcript loading when a session switch fails', async () => {
vi.useFakeTimers();
try {
const existingSession = createMockSession('session-a');
const { actions, getConnection } = createActionsHarness({
connection: { status: 'connected', sessionId: 'session-a' },
session: existingSession,
});
const loadPromise = actions.loadSession('session-b');
expect(getConnection()).toMatchObject({
status: 'connecting',
sessionId: 'session-b',
loadingTranscript: true,
});
vi.advanceTimersByTime(30_000);
await expect(loadPromise).rejects.toThrow('Session load timed out');
expect(getConnection()).toMatchObject({
sessionId: 'session-b',
loadingTranscript: undefined,
catchingUp: undefined,
});
} finally {
vi.useRealTimers();
}
});
it('creates a detached session when the ref and connection do not match', async () => {
const existingSession = createMockSession('session-a');
const nextSession = createMockSession('session-b');

View file

@ -38,7 +38,6 @@ import type {
DaemonSessionActions,
SettledPrompt,
PendingSessionLoad,
SessionSwitchOptions,
} from './types.js';
interface RefBox<T> {
@ -90,6 +89,7 @@ export function getConnectionAfterSessionClear(
return {
...next,
status: 'connected',
loadingTranscript: undefined,
catchingUp: undefined,
error: undefined,
};
@ -189,11 +189,11 @@ export function createDaemonSessionActions({
function startSessionSwitch(
sessionId: string,
mode: 'load' | 'resume',
opts?: SessionSwitchOptions,
): Promise<void> {
manualSessionClearRef.current = false;
const loadPromise = startPendingSessionLoad(sessionId, mode);
const currentSessionId = sessionRef.current?.sessionId;
const currentSession = sessionRef.current;
const currentSessionId = currentSession?.sessionId;
const activePrompt = currentSessionId
? activePromptsRef.current.get(currentSessionId)
: undefined;
@ -204,32 +204,37 @@ export function createDaemonSessionActions({
activePromptsRef.current.delete(currentSessionId);
}
resetCurrentSessionActivePrompt();
if (currentSession) {
void currentSession.detach().catch((error: unknown) => {
console.warn(
'[DaemonSessionActions] detach before session switch failed:',
error,
);
});
}
sessionRef.current = undefined;
setConnection((current) => ({
...current,
status: 'connecting',
sessionId,
clientId: undefined,
displayName: undefined,
error: undefined,
catchingUp: true,
loadingTranscript: true,
catchingUp: undefined,
}));
setPromptStatus('idle');
settledPromptsRef.current.clear();
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (!opts?.deferTranscriptReset) {
store.reset();
}
store.reset();
setRestoreMode(mode);
setRestoreSessionId(sessionId);
setRestoreSessionNonce((nonce) => nonce + 1);
if (!opts?.deferTranscriptReset) {
return loadPromise;
}
return loadPromise.catch((error: unknown) => {
if (!isAbortError(error)) {
store.reset();
const message = error instanceof Error ? error.message : String(error);
setConnection((current) => ({
...current,
status: 'disconnected',
error: message,
loadingTranscript: undefined,
catchingUp: undefined,
}));
}
@ -548,12 +553,12 @@ export function createDaemonSessionActions({
}
},
async loadSession(sessionId, opts) {
return startSessionSwitch(sessionId, 'load', opts);
async loadSession(sessionId) {
return startSessionSwitch(sessionId, 'load');
},
async resumeSession(sessionId, opts) {
return startSessionSwitch(sessionId, 'resume', opts);
async resumeSession(sessionId) {
return startSessionSwitch(sessionId, 'resume');
},
async createSession() {

View file

@ -72,6 +72,8 @@ export interface DaemonConnectionState {
supportedCommands?: DaemonSessionSupportedCommandsStatus;
context?: DaemonSessionContextStatus;
capabilities?: DaemonCapabilities;
/** True while the current session transcript is being loaded. */
loadingTranscript?: boolean;
/** True while replaying buffered events after a reconnect. */
catchingUp?: boolean;
error?: string;
@ -308,8 +310,8 @@ export interface DaemonSessionActions {
listSessions(options?: {
pageSize?: number;
}): Promise<DaemonSessionSummary[]>;
loadSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
resumeSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
loadSession(sessionId: string): Promise<void>;
resumeSession(sessionId: string): Promise<void>;
/**
* Create a daemon session and update local session state. Callers that need
* transcript/event streaming must follow with `attachSession()`.
@ -367,10 +369,6 @@ export interface DaemonSessionActions {
forkSession(directive: string): Promise<DaemonForkSessionResult>;
}
export interface SessionSwitchOptions {
deferTranscriptReset?: boolean;
}
export interface DaemonSessionContextValue {
store: DaemonTranscriptStore;
connection: DaemonConnectionState;