From 98e3c64691873bafbcf8df936f5971a5603ef2a6 Mon Sep 17 00:00:00 2001 From: Krish Garg Date: Sun, 12 Jul 2026 02:58:54 -0700 Subject: [PATCH 1/3] fix(shell-ui): distinguish network from auth failures and surface both Typed ConnectionFailure errors at throw sites, NETWORK_ERROR connection state, no more silent stored-token reconnect failures, and a dismissible recovery banner with retry / re-auth actions. Fixes rocketride-ai/rocketride-saas#305 Co-Authored-By: Claude Fable 5 --- .../layout/ConnectionErrorBanner.tsx | 91 ++++++++++++ .../src/components/layout/ShellLayout.tsx | 2 + apps/shell-ui/src/connection/connection.ts | 135 +++++++++++++++--- apps/shell-ui/src/connection/errors.ts | 14 ++ .../shell-ui/src/connection/remote-manager.ts | 31 ++-- packages/shared-ui/src/types/connection.ts | 17 ++- 6 files changed, 259 insertions(+), 31 deletions(-) create mode 100644 apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx create mode 100644 apps/shell-ui/src/connection/errors.ts 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..8a7c79bce --- /dev/null +++ b/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx @@ -0,0 +1,91 @@ +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 ?? (() => ConnectionManager.getInstance().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..eb0e72d8e 100644 --- a/apps/shell-ui/src/connection/connection.ts +++ b/apps/shell-ui/src/connection/connection.ts @@ -40,12 +40,13 @@ // - 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 } from './errors'; import { getStoredVerifier, clearStoredVerifier } from '../util/pkce'; import { LS_TOKEN, @@ -429,8 +430,26 @@ 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 Promise.race([ + this._attachPromise, + new Promise((_, reject) => + setTimeout(() => reject(new ConnectionFailure('Could not reach the server.', 'network')), 10000), + ), + ]); + } 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 +470,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 +491,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; } } @@ -490,15 +512,15 @@ export class ConnectionManager implements IConnectionManager { if (token) { try { const result = await Promise.race([ - this.client.login(token), + this.loginWithStoredToken(token), new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 8000), + setTimeout(() => reject(new ConnectionFailure('timeout', 'network')), 8000), ), ]); 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 +553,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.connectionStatus.lastFailure = undefined; + // Cache the account info this.accountInfo = result; @@ -558,6 +583,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 +674,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 +716,43 @@ export class ConnectionManager implements IConnectionManager { } } + /** 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. */ @@ -1033,6 +1107,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..83b2ba3f6 --- /dev/null +++ b/apps/shell-ui/src/connection/errors.ts @@ -0,0 +1,14 @@ +// ============================================================================= +// 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'; + } +} diff --git a/apps/shell-ui/src/connection/remote-manager.ts b/apps/shell-ui/src/connection/remote-manager.ts index 86761ec23..424695fc4 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 } from './errors'; // ============================================================================= // REMOTE MANAGER @@ -62,20 +63,32 @@ 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 Promise.race([ + client.login(config.credential as string | { code: string; verifier: string; redirectUri: string }), + new Promise((_, reject) => + setTimeout( + () => reject(new ConnectionFailure(`Connection timed out after ${CONNECT_TIMEOUT_MS}ms`, 'network')), + CONNECT_TIMEOUT_MS, + ), + ), + ]) as ConnectResult; + } 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/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; From 2945b57a1d99a8223fcab365a7bd4e1f4643ce04 Mon Sep 17 00:00:00 2001 From: Krish Garg Date: Sun, 12 Jul 2026 14:54:06 -0700 Subject: [PATCH 2/3] fix(shell-ui): address connection review findings --- .../layout/ConnectionErrorBanner.tsx | 15 ++++++-- apps/shell-ui/src/connection/connection.ts | 35 +++++++++++-------- apps/shell-ui/src/connection/errors.ts | 15 ++++++++ .../shell-ui/src/connection/remote-manager.ts | 14 +++----- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx b/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx index 8a7c79bce..117882e1e 100644 --- a/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx +++ b/apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx @@ -74,13 +74,24 @@ export const ConnectionErrorBanner: React.FC = ({ me : 'Your session has expired — please sign in again.'; const action = isNetworkFailure ? 'Retry' : 'Sign in'; const onAction = isNetworkFailure - ? onRetry ?? (() => ConnectionManager.getInstance().reconnect()) + ? onRetry ?? (() => { + const connection = ConnectionManager.getInstance(); + if (!connection.hasAttached()) { + window.location.reload(); + return; + } + return connection.reconnect(); + }) : onSignIn ?? (() => ConnectionManager.getInstance().startOAuth(false)); return (
{message ?? defaultMessage} -