Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ import { extractTransportPendingMessages } from './transport-queue.js';
import { ingestTimelineEventForCache } from './hooks/useTimeline.js';
import { getMobileKeyboardState } from './mobile-keyboard.js';
import { pickReadableSessionDisplay } from '@shared/session-display.js';
import { getSelectedServerName } from './server-selection.js';
import {
getSelectedServerName,
shouldResetSelectedServer,
shouldShowInitialConnectingGate,
} from './server-selection.js';

// On web: if opened by the native app for passkey auth, render the bridge page.
const nativeCallback = typeof window !== 'undefined'
Expand Down Expand Up @@ -145,6 +149,8 @@ export function App() {
const [splashDone, setSplashDone] = useState(false);

const [servers, setServers] = useState<ServerInfo[]>([]);
const [serversLoaded, setServersLoaded] = useState(false);
const [serversSynced, setServersSynced] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string | null>(
() => localStorage.getItem('rcc_server'),
);
Expand Down Expand Up @@ -174,6 +180,8 @@ export function App() {

useEffect(() => {
if (!auth) {
setServersLoaded(false);
setServersSynced(false);
watchProjectionStore.setApiKey(null);
watchProjectionStore.setSnapshotStatus('switching');
watchProjectionStore.setServers([]);
Expand Down Expand Up @@ -220,6 +228,18 @@ export function App() {
localStorage.removeItem('rcc_server_name');
}, [resolvedSelectedServerName, selectedServerId, selectedServerName, servers.length]);

useEffect(() => {
if (!serversSynced) return;
if (!shouldResetSelectedServer(selectedServerId, servers, serversLoaded)) return;
setSelectedServerId(null);
setSelectedServerName(null);
setSessionsLoaded(true);
setConnecting(false);
localStorage.removeItem('rcc_server');
localStorage.removeItem('rcc_server_name');
localStorage.removeItem('rcc_session');
}, [selectedServerId, servers, serversLoaded]);

useEffect(() => {
let cleanup = () => {};
void onWatchCommand((command) => {
Expand Down Expand Up @@ -504,10 +524,20 @@ export function App() {
try {
const data = await apiFetch<{ servers: ServerInfo[] }>('/api/server');
setServers(data.servers);
} catch { /* ignore */ }
setServersSynced(true);
} catch {
// Preserve the last known list on refresh failures. The request is still
// considered resolved so the UI can escape the initial connecting gate.
} finally {
setServersLoaded(true);
}
}, [auth]);

useEffect(() => { loadServers(); }, [loadServers]);
useEffect(() => {
setServersLoaded(false);
setServersSynced(false);
void loadServers();
}, [loadServers]);

// Periodically refresh server list so lastHeartbeatAt stays current
useEffect(() => {
Expand Down Expand Up @@ -2207,16 +2237,24 @@ export function App() {
// Show full-screen connecting indicator while waiting for initial WS + session data.
// After 8s, show escape buttons so the user is never stuck.
const [connectTimeout, setConnectTimeout] = useState(false);
const showInitialConnectingGate = shouldShowInitialConnectingGate(
Boolean(auth),
selectedServerId,
connected,
sessionsLoaded,
serversLoaded,
);

useEffect(() => {
if (auth && selectedServerId && !connected && servers.length === 0) {
if (showInitialConnectingGate) {
const t = setTimeout(() => setConnectTimeout(true), 5000);
return () => { clearTimeout(t); setConnectTimeout(false); };
}
setConnectTimeout(false);
return undefined;
}, [auth, selectedServerId, connected, servers.length]);
}, [showInitialConnectingGate]);

if (auth && selectedServerId && !sessionsLoaded && !connected && servers.length === 0) {
if (showInitialConnectingGate) {
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0a0e1a', flexDirection: 'column', gap: 16 }}>
<div class="spinner" style={{ width: 32, height: 32 }} />
Expand Down
27 changes: 27 additions & 0 deletions web/src/server-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ export interface SelectableServerInfo {
name: string;
}

export function hasSelectedServer(
selectedServerId: string | null,
servers: readonly SelectableServerInfo[],
): boolean {
if (!selectedServerId) return false;
return servers.some((server) => server.id === selectedServerId);
}

export function getSelectedServerName(
selectedServerId: string | null,
servers: readonly SelectableServerInfo[],
Expand All @@ -12,3 +20,22 @@ export function getSelectedServerName(
if (servers.length === 0) return fallbackName;
return servers.find((server) => server.id === selectedServerId)?.name ?? null;
}

export function shouldResetSelectedServer(
selectedServerId: string | null,
servers: readonly SelectableServerInfo[],
serversLoaded: boolean,
): boolean {
if (!selectedServerId || !serversLoaded) return false;
return !hasSelectedServer(selectedServerId, servers);
}

export function shouldShowInitialConnectingGate(
authReady: boolean,
selectedServerId: string | null,
connected: boolean,
sessionsLoaded: boolean,
serversLoaded: boolean,
): boolean {
return Boolean(authReady && selectedServerId && !sessionsLoaded && !connected && !serversLoaded);
}
47 changes: 46 additions & 1 deletion web/test/server-selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';

import { getSelectedServerName } from '../src/server-selection.js';
import {
getSelectedServerName,
hasSelectedServer,
shouldResetSelectedServer,
shouldShowInitialConnectingGate,
} from '../src/server-selection.js';

describe('getSelectedServerName', () => {
it('uses the persisted fallback before the server list is loaded', () => {
Expand All @@ -26,3 +31,43 @@ describe('getSelectedServerName', () => {
)).toBeNull();
});
});

describe('hasSelectedServer', () => {
it('returns true when the selected server exists in the loaded list', () => {
expect(hasSelectedServer('srv-2', [
{ id: 'srv-1', name: 'Server One' },
{ id: 'srv-2', name: 'Server Two' },
])).toBe(true);
});

it('returns false when the selected server is missing', () => {
expect(hasSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }])).toBe(false);
});
});

describe('shouldResetSelectedServer', () => {
it('does not clear the selection before the server list has loaded', () => {
expect(shouldResetSelectedServer('srv-2', [], false)).toBe(false);
});

it('clears a stale selected server once the server list has loaded', () => {
expect(shouldResetSelectedServer('srv-2', [{ id: 'srv-1', name: 'Server One' }], true)).toBe(true);
});

it('clears the selection when there are no servers after loading', () => {
expect(shouldResetSelectedServer('srv-2', [], true)).toBe(true);
});
});

describe('shouldShowInitialConnectingGate', () => {
it('shows the gate only while the server list is still loading', () => {
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, false)).toBe(true);
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, false, true)).toBe(false);
});

it('does not show the gate without a selected server or after a connection is established', () => {
expect(shouldShowInitialConnectingGate(true, null, false, false, false)).toBe(false);
expect(shouldShowInitialConnectingGate(true, 'srv-1', true, false, false)).toBe(false);
expect(shouldShowInitialConnectingGate(true, 'srv-1', false, true, false)).toBe(false);
});
});
Loading