diff --git a/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts b/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts index a07e421d3..0885b26c8 100644 --- a/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts +++ b/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts @@ -26,7 +26,7 @@ // // Simple auth provider for open-source / local server mode. // User enters an API key (or leaves blank for open access), which is stored -// in sessionStorage and passed to ConnectionManager.connect(). +// in localStorage and passed to ConnectionManager.connect(). // // Implements the same IAuthProvider interface as CloudAuthProvider so the // ConnectionManager can use either interchangeably. @@ -63,7 +63,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { /** * Sign in with an API key. * - * Stores the key in sessionStorage for the current session. An empty string + * Stores the key in localStorage. An empty string * is valid (some OSS servers allow unauthenticated access). * * @param apiKey - The API key to store. @@ -84,7 +84,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ private async storeToken(token: string): Promise { try { - sessionStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[ApiKeyAuthProvider] Failed to store token:', e); } @@ -97,7 +97,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async getToken(): Promise { try { - const token = sessionStorage.getItem(LS_TOKEN); + const token = localStorage.getItem(LS_TOKEN); // For API key mode, empty string is valid (open access) return token; } catch { @@ -111,7 +111,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async isSignedIn(): Promise { try { - return sessionStorage.getItem(LS_TOKEN) !== null; + return localStorage.getItem(LS_TOKEN) !== null; } catch { return false; } @@ -126,7 +126,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async signOut(): Promise { try { - sessionStorage.removeItem(LS_TOKEN); + localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[ApiKeyAuthProvider] Failed to clear token:', e); } diff --git a/apps/shell-ui/src/auth/CloudAuthProvider.ts b/apps/shell-ui/src/auth/CloudAuthProvider.ts index 06b093257..905387b10 100644 --- a/apps/shell-ui/src/auth/CloudAuthProvider.ts +++ b/apps/shell-ui/src/auth/CloudAuthProvider.ts @@ -31,7 +31,7 @@ // 2. Full-page redirect to Zitadel authorize endpoint // 3. Browser redirects back with ?code= parameter // 4. handleCallback() exchanges code for token via the ConnectionManager -// 5. Token stored in sessionStorage (browser equivalent of SecretStorage) +// 5. Token stored in localStorage (browser equivalent of SecretStorage) // // The stored token is picked up by ConnectionManager.connect() — auth is // decoupled from connection, same as VSCode. @@ -173,7 +173,7 @@ export class CloudAuthProvider implements IAuthProvider { } // ========================================================================= - // TOKEN STORAGE (sessionStorage — browser equivalent of SecretStorage) + // TOKEN STORAGE (localStorage — browser equivalent of SecretStorage) // ========================================================================= /** @@ -183,7 +183,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async storeToken(token: string): Promise { try { - sessionStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[CloudAuthProvider] Failed to store token:', e); } @@ -196,7 +196,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async getToken(): Promise { try { - const token = sessionStorage.getItem(LS_TOKEN); + const token = localStorage.getItem(LS_TOKEN); return token || null; } catch { return null; @@ -220,7 +220,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async signOut(): Promise { try { - sessionStorage.removeItem(LS_TOKEN); + localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[CloudAuthProvider] Failed to clear token:', e); } diff --git a/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx b/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx new file mode 100644 index 000000000..117882e1e --- /dev/null +++ b/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx @@ -0,0 +1,102 @@ +import React, { CSSProperties, useEffect, useState } from 'react'; +import { ConnectionState } from 'shared'; +import { ConnectionManager } from '../../connection/connection'; +import { useConnectionStatus } from '../../hooks/useConnectionStatus'; + +const styles = { + banner: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + zIndex: 1000, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + minHeight: 36, + padding: '6px 12px', + backgroundColor: 'var(--rr-bg-paper)', + borderBottom: '1px solid var(--rr-border)', + color: 'var(--rr-text-primary)', + fontFamily: 'var(--rr-font-family)', + fontSize: 'var(--rr-font-size-widget)', + } as CSSProperties, + message: { flex: '0 1 auto' } as CSSProperties, + action: { + border: 0, + background: 'none', + padding: 0, + color: 'var(--rr-brand)', + font: 'inherit', + fontWeight: 600, + cursor: 'pointer', + } as CSSProperties, + dismiss: { + position: 'absolute', + right: 12, + border: 0, + background: 'none', + padding: '0 4px', + color: 'var(--rr-text-secondary)', + fontSize: 20, + lineHeight: 1, + cursor: 'pointer', + } as CSSProperties, +}; + +export interface ConnectionErrorBannerProps { + /** Override the status-derived failure message for other connection errors. */ + message?: string; + /** Override retry handling; defaults to ConnectionManager.reconnect(). */ + onRetry?: () => void | Promise; + /** Override sign-in handling; defaults to ConnectionManager.startOAuth(false). */ + onSignIn?: () => void | Promise; +} + +/** A dismissible recovery banner for authentication and transport failures. */ +export const ConnectionErrorBanner: React.FC = ({ message, onRetry, onSignIn }) => { + const status = useConnectionStatus(); + const [dismissed, setDismissed] = useState(false); + // The failure is latched manager-side (status.lastFailure) so reconnect + // attempts and anonymous connects can't erase it before it renders. + const failure = status.lastFailure; + + useEffect(() => { + setDismissed(false); + }, [failure?.state, failure?.lastError]); + + if (dismissed || !failure) return null; + + const isNetworkFailure = failure.state === ConnectionState.NETWORK_ERROR; + const defaultMessage = isNetworkFailure + ? 'Can\'t reach the server — check your connection and retry.' + : 'Your session has expired — please sign in again.'; + const action = isNetworkFailure ? 'Retry' : 'Sign in'; + const onAction = isNetworkFailure + ? onRetry ?? (() => { + const connection = ConnectionManager.getInstance(); + if (!connection.hasAttached()) { + window.location.reload(); + return; + } + return connection.reconnect(); + }) + : onSignIn ?? (() => ConnectionManager.getInstance().startOAuth(false)); + + return ( +
+ {message ?? defaultMessage} + + +
+ ); +}; diff --git a/apps/shell-ui/src/components/layout/ShellLayout.tsx b/apps/shell-ui/src/components/layout/ShellLayout.tsx index ee86a1588..f3fe1cacc 100644 --- a/apps/shell-ui/src/components/layout/ShellLayout.tsx +++ b/apps/shell-ui/src/components/layout/ShellLayout.tsx @@ -47,6 +47,7 @@ import Sidebar from './Sidebar'; import StatusBar from './StatusBar'; import LoadingScreen from './LoadingScreen'; import DebugPanel from './DebugPanel'; +import { ConnectionErrorBanner } from './ConnectionErrorBanner'; import type { ShellConfig } from '../../workspace/types'; import { commonStyles } from 'shared/themes/styles'; @@ -283,6 +284,7 @@ export const ShellLayout: React.FC = ({
+ {/* Main row: Sidebar | Client Area | Debug Panel */}
{/* Sidebar zone */} diff --git a/apps/shell-ui/src/connection/connection.ts b/apps/shell-ui/src/connection/connection.ts index c99da0a0a..21cbb1f1e 100644 --- a/apps/shell-ui/src/connection/connection.ts +++ b/apps/shell-ui/src/connection/connection.ts @@ -40,12 +40,14 @@ // - Browser-specific session storage for auth phase tracking // ============================================================================= -import { RocketRideClient, ConnectResult } from 'rocketride'; +import { RocketRideClient, ConnectResult, AuthenticationException } from 'rocketride'; import type { ShellConnectionEventMap, IConnectionManager, IAuthProvider } from 'shared'; import { ConnectionState, ConnectionStatus } from 'shared'; import type { ConnectionMode } from 'shared'; import { BaseManager } from './base-manager'; import { RemoteManager } from './remote-manager'; +import { ConnectionFailure, withTimeout } from './errors'; +import { shouldReloadForTokenStorageUpdate } from './tokenStorageUpdate'; import { getStoredVerifier, clearStoredVerifier } from '../util/pkce'; import { LS_TOKEN, @@ -160,6 +162,7 @@ export class ConnectionManager implements IConnectionManager { /** The shared RocketRideClient instance. */ private client: RocketRideClient | null = null; private _attachPromise: Promise | undefined; + private hasAttachedOnce = false; /** Active backend manager (RemoteManager). */ private manager: BaseManager | null = null; @@ -300,7 +303,10 @@ export class ConnectionManager implements IConnectionManager { // Attach immediately so public APIs (rrext_public_*) work before login. // The promise is stored so bootstrap() can await it before login(). - this._attachPromise = this.client.attach().catch((err) => { + this._attachPromise = this.client.attach(); + void this._attachPromise.then(() => { + this.hasAttachedOnce = true; + }).catch((err) => { console.error('[ConnectionManager] Failed to attach:', err); }); @@ -310,6 +316,33 @@ export class ConnectionManager implements IConnectionManager { // click would hit the guard and do nothing. Release it on bfcache restore // so the button works again without a manual page refresh. if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key !== LS_TOKEN) return; + + if (event.newValue === null) { + this.accountInfo = undefined; + this.pendingEvents.clear(); + this.clearServicesCache(); + this.updateConnectionStatus({ + state: ConnectionState.DISCONNECTED, + hasCredentials: false, + lastError: undefined, + progressMessage: undefined, + }); + window.location.reload(); + return; + } + + if (shouldReloadForTokenStorageUpdate({ + oldValue: event.oldValue, + newValue: event.newValue, + currentUserToken: this.accountInfo?.userToken, + hasAccountInfo: Boolean(this.accountInfo), + })) { + window.location.reload(); + } + }); + window.addEventListener('pageshow', (e) => { if ((e as PageTransitionEvent).persisted) { this.oauthStarted = false; @@ -429,8 +462,25 @@ export class ConnectionManager implements IConnectionManager { }): Promise<{ result: ConnectResult; appId: string } | null> { if (!this.client) throw new Error('Client not initialized — call init() first.'); - // Ensure the transport is attached before any login attempt - await this._attachPromise; + // Ensure the transport is attached before any login attempt. Bound the + // wait: with the server fully unreachable the attach can pend forever, + // which used to leave the shell on an indefinite splash. Surfacing it + // as a network failure lets the recovery banner render instead. + try { + await withTimeout( + this._attachPromise!, + 10000, + new ConnectionFailure('Could not reach the server.', 'network'), + ); + } catch (error) { + // Transport attach failures are network problems by definition. + this.handleStoredTokenFailure( + error instanceof ConnectionFailure + ? error + : new ConnectionFailure(error instanceof Error ? error.message : String(error), 'network'), + ); + return null; + } const params = new URLSearchParams(window.location.search); const code = params.get('code'); @@ -451,10 +501,11 @@ export class ConnectionManager implements IConnectionManager { const staleToken = this.loadToken(); if (staleToken) { try { - const result = await this.client.login(staleToken); + const result = await this.loginWithStoredToken(staleToken); return await this.finishConnect(result, sessionAppId, config); - } catch { - this.clearToken(); + } catch (error) { + if (this.handleStoredTokenFailure(error)) this.clearToken(); + else return null; } } // No usable token — restart auth only for session-locked apps. @@ -471,12 +522,14 @@ export class ConnectionManager implements IConnectionManager { const token = this.loadToken(); if (token) { try { - const result = await this.client.login(token); + const result = await this.loginWithStoredToken(token); return await this.finishConnect(result, sessionAppId, config); - } catch { - // Token expired or invalid — clear and restart OAuth - this.clearToken(); - await this.startOAuth(); + } catch (error) { + // Token expired or invalid — clear and restart OAuth. + if (this.handleStoredTokenFailure(error)) { + this.clearToken(); + await this.startOAuth(); + } return null; } } @@ -489,16 +542,15 @@ export class ConnectionManager implements IConnectionManager { const token = this.loadToken(); if (token) { try { - const result = await Promise.race([ - this.client.login(token), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 8000), - ), - ]); + const result = await withTimeout( + this.loginWithStoredToken(token), + 8000, + new ConnectionFailure('timeout', 'network'), + ); return await this.finishConnect(result, '', config); } catch (err) { - // Connect failed — clear stale token - this.clearToken(); + // Connect failed — retain the token when the server is unreachable. + if (this.handleStoredTokenFailure(err)) this.clearToken(); return null; } } @@ -531,6 +583,9 @@ export class ConnectionManager implements IConnectionManager { // Persist the token for future page loads if (result.userToken) this.saveToken(result.userToken); + // An authenticated connect resolves any latched failure (incl. auth). + this.updateConnectionStatus({ lastFailure: undefined }); + // Cache the account info this.accountInfo = result; @@ -558,6 +613,23 @@ export class ConnectionManager implements IConnectionManager { return { result, appId: resolvedAppId }; } + /** Login with a stored token and type failures for bootstrap recovery. */ + private async loginWithStoredToken(token: string): Promise { + try { + const result = await this.client!.login(token); + if (!result.userId) { + throw new ConnectionFailure('Authentication failed: unknown user or invalid credentials.', 'auth'); + } + return result; + } catch (error) { + if (error instanceof ConnectionFailure) throw error; + if (this.getConnectionFailureState(error) === ConnectionState.AUTH_FAILED) { + throw new ConnectionFailure(error instanceof Error ? error.message : String(error), 'auth'); + } + throw new ConnectionFailure(error instanceof Error ? error.message : String(error), 'network'); + } + } + // ========================================================================= // CONNECT / DISCONNECT (mirrors VSCode pattern) // ========================================================================= @@ -632,13 +704,8 @@ export class ConnectionManager implements IConnectionManager { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - // Determine if this is an auth failure vs network failure - const isAuthError = errorMessage.includes('Authentication failed') || - errorMessage.includes('unknown user') || - errorMessage.includes('invalid credentials'); - this.updateConnectionStatus({ - state: isAuthError ? ConnectionState.AUTH_FAILED : ConnectionState.FAILED, + state: this.getConnectionFailureState(error), lastError: errorMessage, progressMessage: undefined, }); @@ -679,6 +746,48 @@ export class ConnectionManager implements IConnectionManager { } } + /** Whether the client has attached successfully during this page lifetime. */ + public hasAttached(): boolean { + return this.hasAttachedOnce; + } + + /** Map a connection failure to the status state that determines recovery UI. */ + private getConnectionFailureState(error: unknown): ConnectionState { + if (error instanceof ConnectionFailure) { + switch (error.kind) { + case 'auth': return ConnectionState.AUTH_FAILED; + case 'network': return ConnectionState.NETWORK_ERROR; + case 'server': return ConnectionState.FAILED; + } + } + + // The SDK types auth rejections (invalid/revoked/expired credentials). + if (error instanceof AuthenticationException) return ConnectionState.AUTH_FAILED; + + const errorMessage = error instanceof Error ? error.message : String(error); + // Legacy fallback for untyped errors from older connection paths. + const isAuthError = errorMessage.includes('Authentication failed') || + errorMessage.includes('unknown user') || + errorMessage.includes('invalid credentials'); + return isAuthError ? ConnectionState.AUTH_FAILED : ConnectionState.FAILED; + } + + /** Surface a stored-token failure and return whether the token should be cleared. */ + private handleStoredTokenFailure(error: unknown): boolean { + const state = this.getConnectionFailureState(error); + const isNetworkFailure = state === ConnectionState.NETWORK_ERROR; + this.updateConnectionStatus({ + state, + lastError: isNetworkFailure + ? 'Can\'t reach the server — check your connection and retry.' + : state === ConnectionState.AUTH_FAILED + ? 'Your session has expired — please sign in again.' + : error instanceof Error ? error.message : String(error), + progressMessage: undefined, + }); + return !isNetworkFailure; + } + /** * Logout: clear auth state, disconnect, and emit shell:logout. */ @@ -758,21 +867,31 @@ export class ConnectionManager implements IConnectionManager { // TOKEN STORAGE // ========================================================================= - /** Persist a user token to sessionStorage. */ + /** Persist a user token to localStorage. */ public saveToken(token: string): void { - try { sessionStorage.setItem(LS_TOKEN, token); } catch (e) { + try { localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[ConnectionManager] Failed to save token:', e); } } - /** Load token from sessionStorage. Returns empty string if unavailable. */ + /** Load token from localStorage. Migrates the old sessionStorage value once. */ public loadToken(): string { - try { return sessionStorage.getItem(LS_TOKEN) ?? ''; } catch { return ''; } + try { + const token = localStorage.getItem(LS_TOKEN); + if (token !== null) return token; + + const sessionToken = sessionStorage.getItem(LS_TOKEN); + if (sessionToken === null) return ''; + + localStorage.setItem(LS_TOKEN, sessionToken); + sessionStorage.removeItem(LS_TOKEN); + return sessionToken; + } catch { return ''; } } /** Clear the persisted token. */ public clearToken(): void { - try { sessionStorage.removeItem(LS_TOKEN); } catch (e) { + try { localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[ConnectionManager] Failed to clear token:', e); } } @@ -1033,6 +1152,25 @@ export class ConnectionManager implements IConnectionManager { /** Update connection status and emit shell:statusChange. */ private updateConnectionStatus(updates: Partial): void { Object.assign(this.connectionStatus, updates); + + // Latch failures across later transitions. The SDK's persist mode keeps + // the state cycling through CONNECTING while it retries, and a + // post-failure anonymous connect reports CONNECTED — either would erase + // a purely state-driven failure before recovery UI could render it. + // A network failure clears once a connection is re-established; an + // auth failure persists until the user acts on it (sign-in navigates). + if (updates.state === ConnectionState.NETWORK_ERROR || updates.state === ConnectionState.AUTH_FAILED) { + this.connectionStatus.lastFailure = { + state: updates.state, + lastError: updates.lastError ?? this.connectionStatus.lastError, + }; + } else if ( + updates.state === ConnectionState.CONNECTED && + this.connectionStatus.lastFailure?.state === ConnectionState.NETWORK_ERROR + ) { + this.connectionStatus.lastFailure = undefined; + } + this.emit('shell:statusChange' as keyof ShellConnectionEventMap, this.connectionStatus as any); // Also emit statusMessage for simple UI consumers diff --git a/apps/shell-ui/src/connection/errors.ts b/apps/shell-ui/src/connection/errors.ts new file mode 100644 index 000000000..b21321c47 --- /dev/null +++ b/apps/shell-ui/src/connection/errors.ts @@ -0,0 +1,29 @@ +// ============================================================================= +// CONNECTION ERRORS — typed failures surfaced by connection backends +// ============================================================================= + +/** A connection failure whose kind determines the UI recovery action. */ +export class ConnectionFailure extends Error { + constructor( + message: string, + public readonly kind: 'auth' | 'network' | 'server', + ) { + super(message); + this.name = 'ConnectionFailure'; + } +} + +/** Race a promise against a timeout and always release the timer. */ +export async function withTimeout(promise: Promise, timeoutMs: number, timeoutError: Error): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(timeoutError), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} diff --git a/apps/shell-ui/src/connection/remote-manager.ts b/apps/shell-ui/src/connection/remote-manager.ts index 86761ec23..ea0e739ef 100644 --- a/apps/shell-ui/src/connection/remote-manager.ts +++ b/apps/shell-ui/src/connection/remote-manager.ts @@ -34,10 +34,11 @@ // No engine process management (that's VSCode-only via LocalManager). // ============================================================================= -import { RocketRideClient, ConnectResult } from 'rocketride'; +import { RocketRideClient, ConnectResult, AuthenticationException } from 'rocketride'; import type { ManagerInfo } from 'shared'; import { BaseManager, ShellConnectionConfig } from './base-manager'; import { CONNECT_TIMEOUT_MS } from '../constants'; +import { ConnectionFailure, withTimeout } from './errors'; // ============================================================================= // REMOTE MANAGER @@ -62,20 +63,28 @@ export class RemoteManager extends BaseManager { async connect(client: RocketRideClient, config: ShellConnectionConfig): Promise { // Validate that we have something to connect with if (!config.credential) { - throw new Error('No credential provided for connection.'); + throw new ConnectionFailure('No credential provided for connection.', 'auth'); } // Login with timeout to avoid indefinite hangs (transport already attached) - const result = await Promise.race([ - client.login(config.credential as string | { code: string; verifier: string; redirectUri: string }), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT_MS}ms`)), CONNECT_TIMEOUT_MS), - ), - ]) as ConnectResult; + let result: ConnectResult; + try { + result = await withTimeout( + client.login(config.credential as string | { code: string; verifier: string; redirectUri: string }), + CONNECT_TIMEOUT_MS, + new ConnectionFailure(`Connection timed out after ${CONNECT_TIMEOUT_MS}ms`, 'network'), + ); + } catch (error) { + if (error instanceof ConnectionFailure) throw error; + if (error instanceof AuthenticationException) { + throw new ConnectionFailure(error.message, 'auth'); + } + throw new ConnectionFailure(error instanceof Error ? error.message : String(error), 'network'); + } // Validate the result — SDK resolves even on auth failure if (!result.userId) { - throw new Error('Authentication failed: unknown user or invalid credentials.'); + throw new ConnectionFailure('Authentication failed: unknown user or invalid credentials.', 'auth'); } // Cache server info if available diff --git a/apps/shell-ui/src/connection/tokenStorageUpdate.ts b/apps/shell-ui/src/connection/tokenStorageUpdate.ts new file mode 100644 index 000000000..bedc13c75 --- /dev/null +++ b/apps/shell-ui/src/connection/tokenStorageUpdate.ts @@ -0,0 +1,18 @@ +export interface TokenStorageUpdate { + oldValue: string | null; + newValue: string | null; + currentUserToken?: string; + hasAccountInfo: boolean; +} + +export function shouldReloadForTokenStorageUpdate(update: TokenStorageUpdate): boolean { + const { oldValue, newValue, currentUserToken, hasAccountInfo } = update; + + if (newValue === null) return false; + + if (oldValue !== null) { + return currentUserToken !== newValue; + } + + return !hasAccountInfo; +} diff --git a/apps/shell-ui/src/constants.ts b/apps/shell-ui/src/constants.ts index e87473bac..31835df7a 100644 --- a/apps/shell-ui/src/constants.ts +++ b/apps/shell-ui/src/constants.ts @@ -28,7 +28,7 @@ // STORAGE KEYS // ============================================================================= -/** sessionStorage: auth token — survives refresh, cleared on tab close. */ +/** localStorage: auth token — persists across tabs, windows, and browser restarts. */ export const LS_TOKEN = 'rr:user_token'; /** diff --git a/packages/shared-ui/src/types/connection.ts b/packages/shared-ui/src/types/connection.ts index ce355f9c5..9bf570972 100644 --- a/packages/shared-ui/src/types/connection.ts +++ b/packages/shared-ui/src/types/connection.ts @@ -51,11 +51,14 @@ export enum ConnectionState { /** Successfully connected and authenticated. */ CONNECTED = 'connected', - /** Connection attempt failed (network, timeout, server error). */ + /** Connection attempt failed due to a server error. */ FAILED = 'failed', /** Authentication was rejected by the server (bad/expired/revoked key). */ AUTH_FAILED = 'auth-failed', + + /** Transport/network failure — retryable. */ + NETWORK_ERROR = 'network-error', } // ============================================================================= @@ -97,6 +100,18 @@ export interface ConnectionStatus { /** Last error message (cleared on successful connect). */ lastError?: string; + /** + * Most recent unrecovered failure, latched across later transitions. + * Persist-mode reconnect attempts report CONNECTING and a post-failure + * anonymous connect reports CONNECTED — recovery UI reads this field so + * the failure stays visible until it is actually resolved. Network + * failures clear on reconnection; auth failures clear on re-auth. + */ + lastFailure?: { + state: ConnectionState.NETWORK_ERROR | ConnectionState.AUTH_FAILED; + lastError?: string; + }; + /** True if we have necessary credentials/config to attempt connection. */ hasCredentials: boolean;