diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx
index e20e187b..a665d2d5 100644
--- a/packages/ui/src/apps/renderMobileApp.tsx
+++ b/packages/ui/src/apps/renderMobileApp.tsx
@@ -76,9 +76,7 @@ export function renderMobileApp(apis: RuntimeAPIs) {
// Auth gating differs by shell: the native Capacitor app authenticates via
// its own instance-connect flow (MobileConnectionWelcome asks for the
// password per instance), while the plain mobile BROWSER against a
- // --ui-password server must get the classic SessionAuthGate unlock page —
- // dropping it (v1.13.9) left browsers on a dead "unable to reach server"
- // screen with no way to enter the password.
+ // --ui-password server must keep the classic SessionAuthGate unlock page.
const app =
= Record = Record | string | symbol;
+
+const hookRecords = new Map >(component: ComponentFn , props: P): unknown => {
+ const previousRecord = currentRecord;
+ const previousHookIndex = hookIndex;
+ currentRecord = getRecord(component);
+ hookIndex = 0;
+
+ try {
+ return component(props);
+ } finally {
+ currentRecord = previousRecord;
+ hookIndex = previousHookIndex;
+ }
+};
+
+function useCallback >(type: JSXElementType , props: JSXProps & P): unknown {
+ if (type === reactJsxRuntime.Fragment) {
+ return props.children ?? null;
+ }
+
+ if (typeof type === 'function') {
+ return renderComponent(type, props as P);
+ }
+
+ return { type, props };
+}
+
+const ReactMock = {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+};
+
+const reactJsxRuntime = {
+ Fragment: Symbol('Fragment'),
+ jsx,
+ jsxs: jsx,
+ jsxDEV: jsx,
+};
+
+let desktopShell = false;
+let runtimeFetchRejects = true;
+
+mock.module('react/jsx-runtime', () => reactJsxRuntime);
+mock.module('react/jsx-dev-runtime', () => reactJsxRuntime);
+
+mock.module('react', () => ({
+ __esModule: true,
+ default: ReactMock,
+ ...ReactMock,
+}));
+
+mock.module('@simplewebauthn/browser', () => ({
+ browserSupportsWebAuthn: mock(() => false),
+}));
+
+mock.module('@/components/ui/button', () => ({
+ Button: ({ children }: { children?: unknown }) => children ?? null,
+}));
+
+mock.module('@/components/ui/checkbox', () => ({
+ Checkbox: () => null,
+}));
+
+mock.module('@/components/ui/input', () => ({
+ Input: () => null,
+}));
+
+mock.module('@/components/ui', () => ({
+ toast: {
+ success: mock(() => undefined),
+ error: mock(() => undefined),
+ message: mock(() => undefined),
+ },
+}));
+
+mock.module('@/components/ui/OpenChamberLogo', () => ({
+ OpenChamberLogo: () => 'logo',
+}));
+
+mock.module('@/components/icon/Icon', () => ({
+ Icon: () => null,
+}));
+
+mock.module('@/components/desktop/DesktopHostSwitcher', () => ({
+ DesktopHostSwitcherInline: () => 'host-switcher',
+}));
+
+mock.module('@/lib/i18n', () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+
+mock.module('@/lib/desktop', () => ({
+ invokeDesktop: mock(() => Promise.resolve(null)),
+ isDesktopShell: mock(() => desktopShell),
+ isVSCodeRuntime: mock(() => false),
+}));
+
+mock.module('@/lib/persistence', () => ({
+ initializeAppearancePreferences: mock(() => Promise.resolve()),
+ syncDesktopSettings: mock(() => Promise.resolve()),
+}));
+
+mock.module('@/lib/directoryPersistence', () => ({
+ applyPersistedDirectoryPreferences: mock(() => Promise.resolve()),
+}));
+
+mock.module('@/lib/runtime-fetch', () => ({
+ runtimeFetch: mock(async () => {
+ if (runtimeFetchRejects) {
+ throw new Error('offline');
+ }
+
+ return new Response(JSON.stringify({ authenticated: false }), {
+ status: 401,
+ headers: { 'content-type': 'application/json' },
+ });
+ }),
+}));
+
+mock.module('@/lib/runtime-auth', () => ({
+ getRuntimeExtraHeadersSync: mock(() => ({})),
+}));
+
+mock.module('@/lib/runtime-switch', () => ({
+ getRuntimeApiBaseUrl: mock(() => ''),
+ subscribeRuntimeEndpointChanged: mock(() => () => {}),
+ switchRuntimeEndpoint: mock(() => undefined),
+}));
+
+mock.module('@/lib/desktopHosts', () => ({
+ desktopHostsGet: mock(() => Promise.resolve(null)),
+ desktopHostsSet: mock(() => Promise.resolve()),
+ getDesktopHostApiUrl: mock(() => ''),
+ normalizeHostUrl: mock(() => ''),
+}));
+
+mock.module('@/lib/passkeys', () => ({
+ authenticateWithPasskey: mock(() => Promise.resolve(null)),
+ cancelPasskeyCeremony: mock(() => undefined),
+ defaultPasskeyStatus: { enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null },
+ fetchPasskeyStatus: mock(() => Promise.resolve({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null })),
+ isPasskeyCeremonyAbort: mock(() => false),
+ registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
+}));
+
+const { SessionAuthGate } = await import('./SessionAuthGate');
+
+const flushEffects = async () => {
+ while (pendingEffects.length > 0) {
+ const effects = pendingEffects;
+ pendingEffects = [];
+ for (const effect of effects) {
+ effect();
+ }
+ await Promise.resolve();
+ }
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await Promise.resolve();
+};
+
+const renderGate = async () => {
+ const firstPass = renderComponent(SessionAuthGate, { children: 'child' });
+ await flushEffects();
+ const secondPass = renderComponent(SessionAuthGate, { children: 'child' });
+ await flushEffects();
+ return secondPass ?? firstPass;
+};
+
+const collectText = (node: unknown): string => {
+ if (node === null || node === undefined || typeof node === 'boolean') return '';
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (Array.isArray(node)) return node.map((child) => collectText(child)).join(' ');
+ if (typeof node === 'object') {
+ const element = node as { props?: { children?: unknown } };
+ return collectText(element.props?.children);
+ }
+ return '';
+};
+
+describe('SessionAuthGate status-check failure behavior', () => {
+ test('keeps non-desktop status-check rejection on the error screen', async () => {
+ resetHarness();
+ desktopShell = false;
+ runtimeFetchRejects = true;
+
+ const tree = await renderGate();
+ const text = collectText(tree);
+
+ expect(text).toContain('sessionAuth.error.networkTitle');
+ expect(text).not.toContain('sessionAuth.locked.unlockTitle');
+ });
+
+ test('keeps desktop-shell status-check rejection on the locked password prompt', async () => {
+ resetHarness();
+ desktopShell = true;
+ runtimeFetchRejects = true;
+
+ const tree = await renderGate();
+ const text = collectText(tree);
+
+ expect(text).toContain('sessionAuth.locked.unlockTitle');
+ expect(text).not.toContain('sessionAuth.error.networkTitle');
+ });
+});
diff --git a/packages/ui/src/components/auth/SessionAuthGate.test.ts b/packages/ui/src/components/auth/SessionAuthGate.test.ts
new file mode 100644
index 00000000..543a6fbe
--- /dev/null
+++ b/packages/ui/src/components/auth/SessionAuthGate.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from 'bun:test';
+
+import { resolveStatusCheckFailureState } from './sessionAuthGateState';
+
+describe('resolveStatusCheckFailureState', () => {
+ test('keeps the desktop-shell password login fallback intact', () => {
+ expect(resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: true })).toBe('locked');
+ });
+
+ test('uses the network error screen for non-desktop status-check failures', () => {
+ expect(resolveStatusCheckFailureState({})).toBe('error');
+ });
+});
diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx
index 00c7c336..558e77c8 100644
--- a/packages/ui/src/components/auth/SessionAuthGate.tsx
+++ b/packages/ui/src/components/auth/SessionAuthGate.tsx
@@ -15,6 +15,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
+import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
@@ -292,8 +293,6 @@ interface SessionAuthGateProps {
children: React.ReactNode;
}
-type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
-
interface ErrorScreenProps {
onRetry: () => void;
errorType?: 'network' | 'rate-limit';
@@ -301,7 +300,9 @@ interface ErrorScreenProps {
children?: React.ReactNode;
}
-export const SessionAuthGate: React.FC