From 993329c98bb912dbed132f93c33b1070f758b7ab Mon Sep 17 00:00:00 2001 From: ytahdn Date: Mon, 6 Jul 2026 11:00:18 +0800 Subject: [PATCH 01/12] fix(web-shell): handle missing session routes --- packages/web-shell/client/App.module.css | 43 ++ packages/web-shell/client/App.test.tsx | 47 +- packages/web-shell/client/App.tsx | 465 ++++++++++-------- packages/web-shell/client/i18n.tsx | 4 + .../session/DaemonSessionProvider.test.tsx | 5 + .../daemon/session/DaemonSessionProvider.tsx | 27 +- packages/webui/src/daemon/session/actions.ts | 3 + packages/webui/src/daemon/session/types.ts | 1 + 8 files changed, 381 insertions(+), 214 deletions(-) diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5372644fd61..618056fe733 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -345,6 +345,49 @@ overflow: visible; } +.missingSessionState { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 24px; + color: var(--foreground); + font-family: var(--font-sans); +} + +.missingSessionMessage { + font-size: inherit; + font-weight: inherit; + line-height: 1.4; + text-align: center; +} + +.missingSessionButton { + min-width: 120px; + min-height: 38px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + font: inherit; + font-size: 14px; + cursor: pointer; + transition: + border-color 120ms ease, + opacity 120ms ease; +} + +.missingSessionButton:hover, +.missingSessionButton:focus-visible { + border-color: color-mix(in srgb, var(--foreground) 24%, var(--border)); + opacity: 0.9; + outline: none; +} + .footer { position: relative; width: min(100%, var(--chat-shell-width)); diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 032527e8237..03978350bce 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; type StreamingState = 'idle' | 'responding'; type MockConnection = { - status: 'connected'; + status: 'connected' | 'disconnected'; sessionId: string | undefined; clientId: string; displayName: string | undefined; @@ -18,6 +18,8 @@ type MockConnection = { capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; + error?: string; + errorStatus?: number; }; type ChatEditorTestProps = { @@ -60,6 +62,10 @@ const { mockSessionActions: { sendPrompt: vi.fn().mockResolvedValue(undefined), createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), + attachSession: vi.fn().mockResolvedValue(undefined), + closeSession: vi.fn().mockResolvedValue(undefined), + clearSession: vi.fn().mockResolvedValue(undefined), + loadSession: vi.fn().mockResolvedValue(undefined), refreshCommands: vi.fn().mockResolvedValue(undefined), setModel: vi.fn().mockResolvedValue(undefined), setApprovalMode: vi.fn().mockResolvedValue(undefined), @@ -316,7 +322,10 @@ beforeEach(() => { }), }); mockConnection.sessionId = 'session-1'; + mockConnection.status = 'connected'; mockConnection.displayName = 'Session One'; + mockConnection.error = undefined; + mockConnection.errorStatus = undefined; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; testState.prompt = 'hello'; @@ -335,6 +344,10 @@ beforeEach(() => { mockSessionActions.createSession.mockResolvedValue({ sessionId: 'session-1', }); + mockSessionActions.attachSession.mockResolvedValue(undefined); + mockSessionActions.closeSession.mockResolvedValue(undefined); + mockSessionActions.clearSession.mockResolvedValue(undefined); + mockSessionActions.loadSession.mockResolvedValue(undefined); mockSessionActions.refreshCommands.mockResolvedValue(undefined); mockSessionActions.setModel.mockResolvedValue(undefined); mockSessionActions.setApprovalMode.mockResolvedValue(undefined); @@ -365,6 +378,38 @@ afterEach(() => { }); describe('App session callbacks', () => { + it.each([404, 410])( + 'shows a missing-session empty state with a new-session action for %d', + async (status) => { + mockConnection.status = 'disconnected'; + mockConnection.sessionId = undefined; + mockConnection.error = 'Session load failed'; + mockConnection.errorStatus = status; + + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ + onSessionIdChange, + }); + await flush(); + + expect(container.textContent).toContain('Current session does not exist'); + expect(container.querySelector('[data-testid="submit"]')).toBeNull(); + expect(onSessionIdChange).not.toHaveBeenCalledWith(undefined); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'New session') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + expect(mockSessionActions.attachSession).not.toHaveBeenCalled(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); + }, + ); + it('gates direct submissions and dispatches submit events with delayed sidebar reload', async () => { vi.useFakeTimers(); const onSubmitBefore = vi.fn().mockResolvedValue(undefined); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c8defa466c8..1d6deae4bc3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -575,6 +575,10 @@ function isAbortError(error: unknown): boolean { ); } +function isMissingSessionErrorStatus(status: number | undefined): boolean { + return status === 404 || status === 410; +} + interface AlreadyDispatchedError extends Error { _alreadyDispatched: true; } @@ -2067,10 +2071,16 @@ export function App({ if (connection.sessionId) { setActiveGoal(null); } + if ( + !connection.sessionId && + isMissingSessionErrorStatus(connection.errorStatus) + ) { + return; + } if (lastNotifiedSessionIdRef.current === connection.sessionId) return; lastNotifiedSessionIdRef.current = connection.sessionId; onSessionIdChange?.(connection.sessionId); - }, [connection.sessionId, onSessionIdChange]); + }, [connection.errorStatus, connection.sessionId, onSessionIdChange]); const lastRenameSessionRef = useRef(undefined); const lastRenameNameRef = useRef(undefined); @@ -2271,6 +2281,17 @@ export function App({ return false; } }, [closeMobileDrawer, reportError, sessionActions]); + const handleMissingSessionNewSession = useCallback(() => { + createNewSession() + .then((success) => { + if (success) { + onSessionIdChange?.(undefined); + } + }) + .catch((error: unknown) => { + reportError(error, 'Failed to start a new chat'); + }); + }, [createNewSession, onSessionIdChange, reportError]); const loadSidebarSession = useCallback( async (sessionId: string) => { @@ -3709,6 +3730,9 @@ export function App({ !showFloatingTodos && !pendingApproval && !btwMessage; + const missingSession = + !connection.sessionId && + isMissingSessionErrorStatus(connection.errorStatus); const effectiveChatWidthMode: ChatWidthMode = isChatEmptyState ? getDefaultChatWidthMode() : chatWidthMode; @@ -4119,226 +4143,247 @@ export function App({ )} - - - +
+ {t('session.missing')} +
+ + + ) : ( + + + - 0 || + pendingApproval + ? styles.contentHasMessages + : undefined, + ] + .filter(Boolean) + .join(' ')} + > + + {btwMessage?.role === 'btw' && ( +
+ +
+ )} + +
+
+ +
+ {canScrollMessageListToBottom && ( +
- {btwMessage?.role === 'btw' && ( -
- -
- )} -
- - - -
- {canScrollMessageListToBottom && ( -
- -
- )} - {showFloatingTodos && ( -
- -
- )} - {pendingToolApproval && ( -
-
+ )} + {showFloatingTodos && ( +
+ +
+ )} + {pendingToolApproval && ( +
+ +
+ )} + {pendingAskUserApproval && ( +
+ +
+ )} +
+ + {escapeHintVisible && streamingState === 'idle' && ( +
+ {t('editor.escClearHint')} +
+ )} + -
- )} - {pendingAskUserApproval && ( -
-
- )} -
- - {escapeHintVisible && streamingState === 'idle' && ( -
- {t('editor.escClearHint')} + {CustomFooter ? ( + 0 + ? (connection.tokenCount ?? 0) / + (connection.contextWindow ?? 0) + : 0 + } + activeGoal={activeGoal} + tasks={footerTasks} + availableModes={MODES_CYCLE} + availableModels={(connection.models ?? []) + .filter(isVisibleComposerModel) + .map((m) => ({ + id: m.id, + label: getModelDisplayName(m.label || m.id), + contextWindow: m.contextWindow, + }))} + skills={loadedSkills} + onSelectMode={handleSetMode} + onSelectModel={handleModelSelect} + /> + ) : ( + + setShowApprovalModeDialog((v) => !v) + } + onSelectModel={() => + setModelDialogMode((v) => (v ? null : 'main')) + } + onShowContext={() => + showContextUsage('/context', false) + } + onOpenSettings={() => setShowSettingsDialog(true)} + ref={statusBarRef} + onOpenTasks={() => openTasksPanel()} + onReturnToInput={handleReturnToEditor} + tasks={backgroundTasks} + activeGoal={activeGoal} + hideSettings={hideSettings} + onToggleShortcuts={handleToggleShortcuts} + compact={true} + /> + )} + {isChatEmptyState && welcomeFooter && ( +
+ {welcomeFooter}
)} - -
- {CustomFooter ? ( - 0 - ? (connection.tokenCount ?? 0) / - (connection.contextWindow ?? 0) - : 0 - } - activeGoal={activeGoal} - tasks={footerTasks} - availableModes={MODES_CYCLE} - availableModels={(connection.models ?? []) - .filter(isVisibleComposerModel) - .map((m) => ({ - id: m.id, - label: getModelDisplayName(m.label || m.id), - contextWindow: m.contextWindow, - }))} - skills={loadedSkills} - onSelectMode={handleSetMode} - onSelectModel={handleModelSelect} - /> - ) : ( - setShowApprovalModeDialog((v) => !v)} - onSelectModel={() => - setModelDialogMode((v) => (v ? null : 'main')) - } - onShowContext={() => showContextUsage('/context', false)} - onOpenSettings={() => setShowSettingsDialog(true)} - ref={statusBarRef} - onOpenTasks={() => openTasksPanel()} - onReturnToInput={handleReturnToEditor} - tasks={backgroundTasks} - activeGoal={activeGoal} - hideSettings={hideSettings} - onToggleShortcuts={handleToggleShortcuts} - compact={true} - /> - )} - {isChatEmptyState && welcomeFooter && ( -
- {welcomeFooter} -
- )} -
- + + )}
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 5501f04696f..5e4d32908bd 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -555,6 +555,8 @@ const EN: Messages = { 'quickActions.shellMode': 'Shell mode', 'quickActions.exitShellMode': 'Exit Shell', 'quickActions.setGoal': 'Set goal', + 'session.missing': 'Current session does not exist', + 'session.new': 'New session', 'sidebar.label': 'Workspace sidebar', 'sidebar.toggleMenu': 'Toggle menu', 'sidebar.newChat': 'New chat', @@ -2011,6 +2013,8 @@ const ZH: Messages = { 'quickActions.shellMode': 'Shell模式', 'quickActions.exitShellMode': '退出Shell', 'quickActions.setGoal': '设置目标', + 'session.missing': '当前会话不存在', + 'session.new': '新建会话', 'sidebar.label': '工作区侧边栏', 'sidebar.toggleMenu': '切换菜单', 'sidebar.newChat': '新对话', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c9a041dfe4c..5f505e150e5 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -5739,6 +5739,11 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ status: 'disconnected', error: 'session gone', + errorStatus: status, + capabilities: { + workspaceCwd: '/mock-workspace', + features: [], + }, }); expect(connection?.sessionId).toBeUndefined(); }, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 32050c2a004..b9fc5350d72 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -399,6 +399,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: undefined, })); const getWorkspaceCapabilities = workspaceGetCapabilitiesRef.current; @@ -489,6 +490,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, sessionId: targetSessionId, error: undefined, + errorStatus: undefined, loadingTranscript: true, })); } @@ -908,6 +910,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: undefined, })); }; for await (const event of activeSession.events({ @@ -1124,6 +1127,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connecting', error: undefined, + errorStatus: undefined, })); break; } @@ -1182,6 +1186,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { status: 'disconnected', sessionId: undefined, error: undefined, + errorStatus: undefined, })); return; } @@ -1214,12 +1219,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'disconnected', error: undefined, + errorStatus: undefined, })); } } catch (error) { if (disposed || abort.signal.aborted) return; const message = error instanceof Error ? error.message : String(error); + const errorStatus = extractHttpStatus(error); const failedSessionId = session?.sessionId; const isAuthFailure = isAuthFailureHttpError(error); const isTerminal = isTerminalSessionHttpError(error); @@ -1258,7 +1265,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { session = undefined; sessionRef.current = undefined; if (isAuthFailure) { - setConnection({ status: 'error', error: message }); + setConnection({ status: 'error', error: message, errorStatus }); return; } setConnection((current) => ({ @@ -1266,6 +1273,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { status: 'disconnected', sessionId: undefined, error: message, + errorStatus, + capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: undefined, })); @@ -1289,6 +1298,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection({ status: 'error', error: message, + errorStatus, }); return; } @@ -1296,6 +1306,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'disconnected', error: message, + errorStatus, loadingTranscript: undefined, })); } @@ -1405,7 +1416,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (consecutiveFailures >= heartbeatFailureThreshold) { setConnection((current) => current.sessionId === session.sessionId - ? { ...current, status: 'connected', error: undefined } + ? { + ...current, + status: 'connected', + error: undefined, + errorStatus: undefined, + } : current, ); } @@ -1419,7 +1435,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error instanceof Error ? error.message : 'Session heartbeat failed'; setConnection((current) => current.sessionId === session.sessionId - ? { ...current, status: 'disconnected', error: message } + ? { + ...current, + status: 'disconnected', + error: message, + errorStatus: extractHttpStatus(error), + } : current, ); }); diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index efa47f22994..cdd76ef488d 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -101,6 +101,7 @@ export function getConnectionAfterSessionClear( loadingTranscript: undefined, catchingUp: undefined, error: undefined, + errorStatus: undefined, }; } @@ -229,6 +230,7 @@ export function createDaemonSessionActions({ clientId: undefined, displayName: undefined, error: undefined, + errorStatus: undefined, loadingTranscript: true, catchingUp: undefined, })); @@ -617,6 +619,7 @@ export function createDaemonSessionActions({ ...(nextSession.clientId ? { clientId: nextSession.clientId } : {}), workspaceCwd: nextSession.workspaceCwd, error: undefined, + errorStatus: undefined, })); return nextSession; } catch (error) { diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index ef8a0a7f389..f13309f972e 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -77,6 +77,7 @@ export interface DaemonConnectionState { /** True while replaying buffered events after a reconnect. */ catchingUp?: boolean; error?: string; + errorStatus?: number; } export interface DaemonTokenUsage { From 35a5b60f70a16ae59b7298fc5bd0c88ad129a303 Mon Sep 17 00:00:00 2001 From: ytahdn Date: Mon, 6 Jul 2026 11:04:53 +0800 Subject: [PATCH 02/12] chore(web-shell): clarify missing session route handling --- packages/web-shell/client/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1d6deae4bc3..b6d593b166f 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2075,6 +2075,7 @@ export function App({ !connection.sessionId && isMissingSessionErrorStatus(connection.errorStatus) ) { + // Keep the missing-session route visible until the user chooses a new chat. return; } if (lastNotifiedSessionIdRef.current === connection.sessionId) return; From 0468bdfac822c0c47f077102ef1893073f42880c Mon Sep 17 00:00:00 2001 From: ytahdn Date: Mon, 6 Jul 2026 13:24:49 +0800 Subject: [PATCH 03/12] fix(web-shell): address missing session review follow-up --- packages/web-shell/client/App.tsx | 581 +++++++++--------- .../daemon/session/DaemonSessionProvider.tsx | 52 +- 2 files changed, 342 insertions(+), 291 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index ed0b19037be..2965749d6ba 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4386,7 +4386,8 @@ export function App({ chatWidthMode={chatWidthMode} onChatWidthModeChange={handleChatWidthModeChange} onSubDialog={(key) => { - if (key === 'fastModel') setModelDialogMode('fast'); + if (key === 'fastModel') + setModelDialogMode('fast'); else if (key === 'visionModel') setModelDialogMode('vision'); else if (key === 'tools.approvalMode') @@ -4400,62 +4401,67 @@ export function App({ )} {mainView === 'scheduledTasks' && ( -
-
- -
- {t('scheduledTasks.title')} -
-
-
- { - // Manual trigger reuses the normal prompt path: return - // to the chat view and send the task's prompt into the - // current session so the run streams in the chat. - setMainView('chat'); - sendPrompt(taskPrompt).catch((error: unknown) => { - reportError(error, 'Failed to run scheduled task'); - }); - }} - onCreateViaChat={() => { - // Return to chat and prime the composer so the user can - // describe the task in natural language; the agent - // creates it via its cron_create tool. Deferred so the - // composer is mounted/visible before we focus it. - setMainView('chat'); - window.setTimeout(() => { - editorRef.current?.insertText( - t('scheduledTasks.chatStarter'), - { mode: 'replace' }, - ); - editorRef.current?.focus(); - }, 0); - }} - onError={reportError} - /> -
-
+
+ +
+ {t('scheduledTasks.title')} +
+
+
+ { + // Manual trigger reuses the normal prompt path: return + // to the chat view and send the task's prompt into the + // current session so the run streams in the chat. + setMainView('chat'); + sendPrompt(taskPrompt).catch((error: unknown) => { + reportError( + error, + 'Failed to run scheduled task', + ); + }); + }} + onCreateViaChat={() => { + // Return to chat and prime the composer so the user can + // describe the task in natural language; the agent + // creates it via its cron_create tool. Deferred so the + // composer is mounted/visible before we focus it. + setMainView('chat'); + window.setTimeout(() => { + editorRef.current?.insertText( + t('scheduledTasks.chatStarter'), + { mode: 'replace' }, + ); + editorRef.current?.focus(); + }, 0); + }} + onError={reportError} + /> +
)} diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 953106660bf..c2f69d08a09 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -5075,6 +5075,56 @@ describe('DaemonSessionProvider', () => { expect(connection?.sessionId).toBeUndefined(); }); + it('preserves missing-session heartbeat status across later HTTP failures', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); + const heartbeat = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('session gone'), { status: 410 }), + ) + .mockRejectedValue( + Object.assign(new Error('server error'), { status: 500 }), + ); + sdkMocks.sessions.push( + createMockSession({ + heartbeat, + events: createIdleEvents(), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 2, + }); + + await act(async () => { + await wait(10); + await flushPromises(); + }); + + expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + }); + expect(connection?.sessionId).toBeUndefined(); + }); + it('clears prompt state on terminal HTTP heartbeat errors', async () => { sdkMocks.capabilities.mockResolvedValue({ v: 1, @@ -5961,6 +6011,51 @@ describe('DaemonSessionProvider', () => { }, ); + it('clears missing-session state when starting a new session', async () => { + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce( + Object.assign(new Error('session gone'), { status: 410 }), + ); + sdkMocks.sessions.push(createMockSession({ sessionId: 'new-session' })); + + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + sessionId: 'missing-session', + }); + + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'disconnected', + error: 'session gone', + errorStatus: 410, + missingSession: true, + }); + + await act(async () => { + await actions?.newSession(); + await wait(5); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'new-session', + }); + expect(connection?.error).toBeUndefined(); + expect(connection?.errorStatus).toBeUndefined(); + expect(connection?.missingSession).not.toBe(true); + }); + it.each([401, 403])( 'preserves transcript and clears prompt state on %d auth failures from the SSE stream', async (status) => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a7a54342448..b934755ecc0 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -169,6 +169,13 @@ const TERMINAL_SESSION_HTTP_STATUSES = new Set([ ...AUTH_FAILURE_HTTP_STATUSES, ...MISSING_SESSION_HTTP_STATUSES, ]); + +interface HeartbeatFailureState { + sessionId?: string; + consecutiveFailures: number; + lastHttpError?: { status: number; message: string }; +} + // Keep enough transcript history for large daemon replay streams so event order // and subagent grouping survive replay. Rendering is virtualized, but message // normalization still rebuilds from retained blocks today, so this high default @@ -250,6 +257,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ReturnType | undefined >(undefined); const heartbeatSupportedRef = useRef(false); + const heartbeatFailureStateRef = useRef({ + consecutiveFailures: 0, + }); const manualSessionClearRef = useRef(false); const skipNextCleanupDetachSessionIdRef = useRef( undefined, @@ -1324,7 +1334,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { errorStatus, current.errorStatus, ), - missingSession: missingLoadedSession, + // SSE errors should not create the missing-session empty state, + // but they also must not clear one confirmed by load/heartbeat. + missingSession: + missingLoadedSession || current.missingSession === true, capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: undefined, @@ -1464,9 +1477,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return undefined; } + if (heartbeatFailureStateRef.current.sessionId !== connection.sessionId) { + heartbeatFailureStateRef.current = { + sessionId: connection.sessionId, + consecutiveFailures: 0, + }; + } + const heartbeatFailureState = heartbeatFailureStateRef.current; let disposed = false; - let consecutiveFailures = 0; - let lastHttpError: { status: number; message: string } | undefined; const timer = setInterval(() => { const session = sessionRef.current; if (!session) return; @@ -1474,7 +1492,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { .heartbeat() .then(() => { if (disposed) return; - if (consecutiveFailures >= heartbeatFailureThreshold) { + if ( + heartbeatFailureState.consecutiveFailures >= + heartbeatFailureThreshold + ) { setConnection((current) => current.sessionId === session.sessionId ? { @@ -1486,24 +1507,35 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : current, ); } - consecutiveFailures = 0; - lastHttpError = undefined; + heartbeatFailureState.consecutiveFailures = 0; + heartbeatFailureState.lastHttpError = undefined; }) .catch((error: unknown) => { if (disposed) return; - consecutiveFailures += 1; + heartbeatFailureState.consecutiveFailures += 1; const message = error instanceof Error ? error.message : 'Session heartbeat failed'; const thisErrorStatus = extractHttpStatus(error); if (thisErrorStatus !== undefined) { - lastHttpError = { status: thisErrorStatus, message }; + const lastStatus = heartbeatFailureState.lastHttpError?.status; + heartbeatFailureState.lastHttpError = { + status: + resolveConnectionErrorStatus(thisErrorStatus, lastStatus) ?? + thisErrorStatus, + message: isMissingSessionHttpStatus(lastStatus) + ? (heartbeatFailureState.lastHttpError?.message ?? message) + : message, + }; } - if (consecutiveFailures < heartbeatFailureThreshold) return; - const errorStatus = thisErrorStatus ?? lastHttpError?.status; + if ( + heartbeatFailureState.consecutiveFailures < + heartbeatFailureThreshold + ) { + return; + } + const errorStatus = heartbeatFailureState.lastHttpError?.status; const effectiveMessage = - thisErrorStatus !== undefined - ? message - : (lastHttpError?.message ?? message); + heartbeatFailureState.lastHttpError?.message ?? message; const authFailure = errorStatus !== undefined && AUTH_FAILURE_HTTP_STATUSES.has(errorStatus); @@ -1529,6 +1561,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); setPromptStatus('idle'); if (sessionRef.current?.sessionId === deadSessionId) { + if (missingSession) { + manualSessionClearRef.current = true; + } sessionRef.current = undefined; } } diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index b45de5e7c55..e420cd31c8d 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -77,7 +77,9 @@ export interface DaemonConnectionState { /** True while replaying buffered events after a reconnect. */ catchingUp?: boolean; error?: string; + /** Latest HTTP error status kept for diagnostics; use missingSession for UI. */ errorStatus?: number; + /** True only when the server confirmed the current session is missing. */ missingSession?: boolean; } From 9f7385f48fe4ed019fa7a12fc54ad88a2bae7304 Mon Sep 17 00:00:00 2001 From: ytahdn Date: Tue, 7 Jul 2026 04:10:13 +0800 Subject: [PATCH 12/12] fix(webui): cover missing session review gaps --- packages/web-shell/client/App.module.css | 9 +++++++++ .../src/daemon/session/DaemonSessionProvider.test.tsx | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index e65eb4572c2..27ddf37b603 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -440,9 +440,18 @@ .missingSessionButton:focus-visible { border-color: color-mix(in srgb, var(--foreground) 24%, var(--border)); opacity: 0.9; +} + +.missingSessionButton:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); outline: none; } +.missingSessionButton:disabled { + cursor: not-allowed; + opacity: 0.5; +} + /* In-place panel that replaces the chat view (message list + composer) when Settings or Daemon Status is opened. Fills the chat pane and owns its own scroll container, mirroring what the DialogShell body used to provide. */ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c2f69d08a09..3a89994b880 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -6118,7 +6118,14 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ status: 'error', error: 'Unauthorized', + errorStatus: status, + missingSession: false, + capabilities: { + workspaceCwd: '/mock-workspace', + features: [], + }, }); + expect(connection?.sessionId).toBeUndefined(); expect(blocks[0]).toMatchObject({ kind: 'user', text: 'keep transcript',