mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
e3076e2990
commit
b1ec04f4bd
10 changed files with 844 additions and 154 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': '历史搜索:',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue