fix: keep instance statuses between switcher opens
The switcher held reachability in component state and replaced the whole map at the end of a probe run. It ran once per open before the config had loaded — with Local as the only host — so that pass wiped every other instance's status and each open started on "Checking", including for the instance the app was connected to and actively talking to. Statuses move to their own module: startup warms them so the switcher opens on real values, a re-probe replaces each value in place as it lands rather than blanking them first, and stale entries are dropped against the loaded config instead of a partial host list. The connected instance never reads "Checking" — the live connection already answers what the probe would ask.
This commit is contained in:
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
|
||||
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
desktopHostProbe,
|
||||
@@ -37,6 +38,14 @@ import {
|
||||
resolveCurrentDesktopHost,
|
||||
runtimeKeyForDesktopHost,
|
||||
} from '@/lib/desktopCurrentHost';
|
||||
import {
|
||||
getDesktopHostStatusSnapshot,
|
||||
probeDesktopHosts,
|
||||
setDesktopHostStatus,
|
||||
pruneDesktopHostStatuses,
|
||||
subscribeDesktopHostStatuses,
|
||||
type DesktopHostStatus,
|
||||
} from '@/lib/desktopHostStatus';
|
||||
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
|
||||
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
@@ -52,17 +61,7 @@ import {
|
||||
const SSH_CONNECT_TIMEOUT_MS = 90_000;
|
||||
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
|
||||
|
||||
type HostStatus = {
|
||||
status: HostProbeResult['status'];
|
||||
latencyMs: number;
|
||||
/** Which transport the successful probe used (multi-transport hosts). */
|
||||
via?: 'relay';
|
||||
};
|
||||
|
||||
// Last known statuses survive the dropdown unmounting (it remounts on every
|
||||
// open). Rows show the previous result immediately — refreshed quietly by the
|
||||
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
|
||||
const lastKnownHostStatuses: Record<string, HostStatus> = {};
|
||||
type HostStatus = DesktopHostStatus;
|
||||
|
||||
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
|
||||
|
||||
@@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({
|
||||
const { t } = useI18n();
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const isRuntimeConnected = useConfigStore((state) => state.isConnected);
|
||||
|
||||
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
||||
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
||||
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
|
||||
React.useEffect(() => {
|
||||
Object.assign(lastKnownHostStatuses, statusById);
|
||||
}, [statusById]);
|
||||
// Statuses live outside this component: startup warms them, and the dropdown
|
||||
// remounts on every open — holding them here is what made each open start
|
||||
// from nothing and show "Checking" on rows the app already knew about.
|
||||
const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot);
|
||||
const statusById = statusSnapshot.byHostId;
|
||||
const isProbing = statusSnapshot.isProbing;
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isProbing, setIsProbing] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
|
||||
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
|
||||
@@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({
|
||||
nextSshHostIds[instance.id] = true;
|
||||
}
|
||||
setConfigHosts(cfg.hosts || []);
|
||||
// Config is the authoritative host list: drop statuses for instances the
|
||||
// user removed. Doing this from a probe run instead would clear entries
|
||||
// every time a run started before the config had finished loading.
|
||||
pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id));
|
||||
setDefaultHostId(cfg.defaultHostId ?? null);
|
||||
setSshHostIds(nextSshHostIds);
|
||||
setSshStatusesById(sshStatusMap);
|
||||
@@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [t]);
|
||||
|
||||
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
||||
if (!isDesktopShell()) return;
|
||||
setIsProbing(true);
|
||||
try {
|
||||
const localClientToken = await getLocalClientToken();
|
||||
const results = await Promise.all(
|
||||
hosts.map(async (h) => {
|
||||
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
|
||||
const probeRelayLeg = async (): Promise<HostStatus> => {
|
||||
const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
|
||||
};
|
||||
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
|
||||
if (h.relay && !h.apiUrl) {
|
||||
return [h.id, await probeRelayLeg()] as const;
|
||||
}
|
||||
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
|
||||
if (!url) {
|
||||
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
|
||||
}
|
||||
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
// Multi-transport host away from its network: the direct leg fails
|
||||
// but the relay may still reach it.
|
||||
if (isBlockedHostStatus(res.status) && h.relay) {
|
||||
const relayStatus = await probeRelayLeg();
|
||||
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
|
||||
}
|
||||
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
|
||||
})
|
||||
);
|
||||
const next: Record<string, HostStatus> = {};
|
||||
for (const [id, val] of results) {
|
||||
next[id] = val;
|
||||
}
|
||||
setStatusById(next);
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
await probeDesktopHosts(hosts);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({
|
||||
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
|
||||
}
|
||||
}
|
||||
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
|
||||
setDesktopHostStatus(host.id, finalStatus);
|
||||
|
||||
if (!transport) {
|
||||
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
||||
@@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({
|
||||
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
|
||||
setSwitchingHostId(host.id);
|
||||
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
setStatusById((prev) => ({
|
||||
...prev,
|
||||
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
|
||||
}));
|
||||
setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs });
|
||||
|
||||
if (isBlockedHostStatus(probe.status)) {
|
||||
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
||||
@@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({
|
||||
const status = statusById[host.id] || null;
|
||||
const sshStatus = sshStatusesById[host.id] || null;
|
||||
// While a probe runs, keep showing the last known result (quiet
|
||||
// refresh); only fall back to "Checking" when there has never
|
||||
// been one. "Unknown" is never shown — an unprobed host is by
|
||||
// definition being checked.
|
||||
// refresh — the header's refresh icon is the spinner); only fall
|
||||
// back to "Checking" when there has never been one. "Unknown" is
|
||||
// never shown — an unprobed host is by definition being checked.
|
||||
//
|
||||
// The instance the app is connected to never says "Checking":
|
||||
// the live connection already answers the question a probe would
|
||||
// ask, and reporting otherwise reads as the app not knowing where
|
||||
// it is. A real probe result still wins — it carries the ping.
|
||||
const statusKind: HostDisplayStatus = isSsh
|
||||
? sshPhaseToHostStatus(sshStatus?.phase)
|
||||
: (status?.status ?? 'checking');
|
||||
: (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking'));
|
||||
const isEditing = editingId === host.id;
|
||||
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
|
||||
const displayLabel = host.id === LOCAL_HOST_ID
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { DesktopHost, HostProbeResult } from './desktopHosts';
|
||||
|
||||
let probeResults: Record<string, HostProbeResult> = {};
|
||||
const probeCalls: string[] = [];
|
||||
let probeGate: Promise<void> | null = null;
|
||||
|
||||
const desktopModule = await import('./desktopHosts');
|
||||
mock.module('./desktopHosts', () => ({
|
||||
...desktopModule,
|
||||
desktopLocalClientTokenGet: async () => 'local-token',
|
||||
desktopHostProbe: async (url: string) => {
|
||||
probeCalls.push(url);
|
||||
if (probeGate) await probeGate;
|
||||
return probeResults[url] ?? { status: 'unreachable', latencyMs: 0 };
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopShell = await import('@/lib/desktop');
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
...desktopShell,
|
||||
isDesktopShell: () => true,
|
||||
isElectronShell: () => false,
|
||||
}));
|
||||
|
||||
const {
|
||||
getDesktopHostStatusSnapshot,
|
||||
probeDesktopHosts,
|
||||
pruneDesktopHostStatuses,
|
||||
setDesktopHostStatus,
|
||||
subscribeDesktopHostStatuses,
|
||||
} = await import('./desktopHostStatus');
|
||||
|
||||
const host = (id: string, url: string): DesktopHost => ({ id, label: id, url });
|
||||
|
||||
describe('desktop host statuses', () => {
|
||||
beforeEach(() => {
|
||||
probeResults = {};
|
||||
probeCalls.length = 0;
|
||||
probeGate = null;
|
||||
pruneDesktopHostStatuses([]);
|
||||
setDesktopHostStatus('local', { status: 'ok', latencyMs: 1 });
|
||||
pruneDesktopHostStatuses([]);
|
||||
});
|
||||
|
||||
test('a probe replaces the previous value instead of blanking it first', async () => {
|
||||
setDesktopHostStatus('remote', { status: 'ok', latencyMs: 12 });
|
||||
probeResults['https://remote.example'] = { status: 'ok', latencyMs: 40 };
|
||||
|
||||
const seen: Array<string | undefined> = [];
|
||||
const unsubscribe = subscribeDesktopHostStatuses(() => {
|
||||
seen.push(getDesktopHostStatusSnapshot().byHostId.remote?.status);
|
||||
});
|
||||
await probeDesktopHosts([host('remote', 'https://remote.example')]);
|
||||
unsubscribe();
|
||||
|
||||
// Every published snapshot during the run still carried a status; the row
|
||||
// never falls back to "Checking" while a quiet refresh is running.
|
||||
expect(seen.every((status) => status !== undefined)).toBe(true);
|
||||
expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(40);
|
||||
});
|
||||
|
||||
test('a fast host is published while a slow one is still in flight', async () => {
|
||||
probeResults['https://fast.example'] = { status: 'ok', latencyMs: 5 };
|
||||
probeResults['https://slow.example'] = { status: 'ok', latencyMs: 900 };
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
|
||||
probeGate = slowGate;
|
||||
|
||||
const run = probeDesktopHosts([host('fast', 'https://fast.example'), host('slow', 'https://slow.example')]);
|
||||
await Promise.resolve();
|
||||
expect(getDesktopHostStatusSnapshot().isProbing).toBe(true);
|
||||
|
||||
releaseSlow();
|
||||
await run;
|
||||
|
||||
expect(getDesktopHostStatusSnapshot().byHostId.fast?.status).toBe('ok');
|
||||
expect(getDesktopHostStatusSnapshot().byHostId.slow?.status).toBe('ok');
|
||||
expect(getDesktopHostStatusSnapshot().isProbing).toBe(false);
|
||||
});
|
||||
|
||||
test('pruning keeps local and every configured instance, and forgets the rest', () => {
|
||||
setDesktopHostStatus('kept', { status: 'ok', latencyMs: 3 });
|
||||
setDesktopHostStatus('removed', { status: 'ok', latencyMs: 4 });
|
||||
|
||||
pruneDesktopHostStatuses(['kept']);
|
||||
|
||||
const { byHostId } = getDesktopHostStatusSnapshot();
|
||||
expect(byHostId.kept?.status).toBe('ok');
|
||||
expect(byHostId.local?.status).toBe('ok');
|
||||
expect(byHostId.removed).toBe(undefined);
|
||||
});
|
||||
|
||||
test('a snapshot is a new object per change so subscribers re-render', () => {
|
||||
const before = getDesktopHostStatusSnapshot();
|
||||
setDesktopHostStatus('remote', { status: 'auth', latencyMs: 0 });
|
||||
|
||||
expect(getDesktopHostStatusSnapshot()).not.toBe(before);
|
||||
expect(before.byHostId.remote).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isDesktopShell, isElectronShell } from '@/lib/desktop';
|
||||
import {
|
||||
desktopHostProbe,
|
||||
desktopHostsGet,
|
||||
desktopLocalClientTokenGet,
|
||||
getDesktopHostApiUrl,
|
||||
normalizeHostUrl,
|
||||
probeRelayDesktopHost,
|
||||
type DesktopHost,
|
||||
type HostProbeResult,
|
||||
} from '@/lib/desktopHosts';
|
||||
import { LOCAL_HOST_ID, buildLocalDesktopHost } from '@/lib/desktopCurrentHost';
|
||||
|
||||
export type DesktopHostStatus = {
|
||||
status: HostProbeResult['status'];
|
||||
latencyMs: number;
|
||||
/** Which transport the successful probe used (multi-transport hosts). */
|
||||
via?: 'relay';
|
||||
};
|
||||
|
||||
/** Reachability by instance id. */
|
||||
type DesktopHostStatusMap = Record<string, DesktopHostStatus>;
|
||||
|
||||
type DesktopHostStatusSnapshot = {
|
||||
byHostId: Readonly<DesktopHostStatusMap>;
|
||||
/** True while any probe run is in flight, for the refresh spinner. */
|
||||
isProbing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reachability of every configured instance, owned outside the switcher UI.
|
||||
*
|
||||
* The switcher used to hold this in component state, which made the dropdown
|
||||
* the only thing that could ever learn an instance's status: every open started
|
||||
* from nothing and showed "Checking" on rows the app had already answered for —
|
||||
* including the instance the app was connected to and actively talking to.
|
||||
*
|
||||
* Keeping it here lets startup warm the statuses before the user opens
|
||||
* anything, and lets a re-probe replace values in place instead of blanking
|
||||
* them first.
|
||||
*/
|
||||
const statuses = new Map<string, DesktopHostStatus>();
|
||||
let activeProbeRuns = 0;
|
||||
let snapshot: DesktopHostStatusSnapshot = { byHostId: {}, isProbing: false };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const publishSnapshot = (): void => {
|
||||
// `useSyncExternalStore` compares snapshots by identity, so each mutation
|
||||
// publishes a fresh one rather than handing out the live map.
|
||||
snapshot = { byHostId: Object.fromEntries(statuses), isProbing: activeProbeRuns > 0 };
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// A subscriber throwing must not stop the others.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeDesktopHostStatuses = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
};
|
||||
|
||||
export const getDesktopHostStatusSnapshot = (): DesktopHostStatusSnapshot => snapshot;
|
||||
|
||||
const setStatus = (hostId: string, status: DesktopHostStatus): void => {
|
||||
statuses.set(hostId, status);
|
||||
publishSnapshot();
|
||||
};
|
||||
|
||||
/** Record a status learned outside a probe run — the switch flow probes too. */
|
||||
export const setDesktopHostStatus = (hostId: string, status: DesktopHostStatus): void => {
|
||||
setStatus(hostId, status);
|
||||
};
|
||||
|
||||
/**
|
||||
* Forget instances that are no longer configured. Called with the authoritative
|
||||
* host list, never with a partially loaded one — dropping entries on a list
|
||||
* that has not finished loading is what made every dropdown open start blank.
|
||||
*/
|
||||
export const pruneDesktopHostStatuses = (configuredHostIds: readonly string[]): void => {
|
||||
const keep = new Set([LOCAL_HOST_ID, ...configuredHostIds]);
|
||||
let changed = false;
|
||||
for (const hostId of Array.from(statuses.keys())) {
|
||||
if (keep.has(hostId)) continue;
|
||||
statuses.delete(hostId);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) publishSnapshot();
|
||||
};
|
||||
|
||||
const isBlockedProbeStatus = (status: HostProbeResult['status']): boolean =>
|
||||
status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
|
||||
|
||||
const getLocalClientToken = async (): Promise<string> => {
|
||||
if (!isElectronShell()) return '';
|
||||
return desktopLocalClientTokenGet().catch(() => '');
|
||||
};
|
||||
|
||||
const probeHost = async (host: DesktopHost, localClientToken: string): Promise<DesktopHostStatus> => {
|
||||
const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || '');
|
||||
const probeRelayLeg = async (): Promise<DesktopHostStatus> => {
|
||||
const res = await probeRelayDesktopHost(host.relay!, { clientToken, requestHeaders: host.requestHeaders || null })
|
||||
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
const status: DesktopHostStatus = { status: res.status, latencyMs: res.latencyMs };
|
||||
// `via` is what renders the "· Relay" suffix, so it marks a reachable host
|
||||
// only — a failed relay leg says nothing about which transport would work.
|
||||
if (res.status === 'ok') status.via = 'relay';
|
||||
return status;
|
||||
};
|
||||
|
||||
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
|
||||
if (host.relay && !host.apiUrl) return probeRelayLeg();
|
||||
|
||||
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(host) : host.url);
|
||||
if (!url) return { status: 'unreachable', latencyMs: 0 };
|
||||
|
||||
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null })
|
||||
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
// Multi-transport host away from its network: the direct leg fails but the
|
||||
// relay may still reach it.
|
||||
if (isBlockedProbeStatus(res.status) && host.relay) {
|
||||
const relayStatus = await probeRelayLeg();
|
||||
if (relayStatus.status === 'ok') return relayStatus;
|
||||
}
|
||||
return { status: res.status, latencyMs: res.latencyMs };
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe every given instance, publishing each result the moment it lands.
|
||||
* Waiting for the slowest probe would hold answered rows on "Checking" beside
|
||||
* one host still working through its relay tunnel retries.
|
||||
*/
|
||||
export const probeDesktopHosts = async (hosts: readonly DesktopHost[]): Promise<void> => {
|
||||
if (!isDesktopShell()) return;
|
||||
activeProbeRuns += 1;
|
||||
publishSnapshot();
|
||||
try {
|
||||
const localClientToken = await getLocalClientToken();
|
||||
await Promise.all(hosts.map(async (host) => {
|
||||
setStatus(host.id, await probeHost(host, localClientToken));
|
||||
}));
|
||||
} finally {
|
||||
activeProbeRuns -= 1;
|
||||
publishSnapshot();
|
||||
}
|
||||
};
|
||||
|
||||
let warmUpStarted = false;
|
||||
|
||||
/**
|
||||
* Learn every instance's status once at startup, so the switcher opens on real
|
||||
* values instead of probing for the first time under the user's cursor.
|
||||
*
|
||||
* Deliberately after the app's own bootstrap: this is background work, and the
|
||||
* direct legs go through the Electron main process while relay legs open their
|
||||
* own WebSocket, so neither shares the renderer's connection pool with session
|
||||
* traffic — but the machine's network is still busiest right at launch.
|
||||
*/
|
||||
export const warmDesktopHostStatuses = async (): Promise<void> => {
|
||||
if (warmUpStarted || !isDesktopShell()) return;
|
||||
warmUpStarted = true;
|
||||
const config = await desktopHostsGet().catch(() => null);
|
||||
if (!config) return;
|
||||
pruneDesktopHostStatuses(config.hosts.map((host) => host.id));
|
||||
await probeDesktopHosts([buildLocalDesktopHost(config.localOrigin), ...config.hosts]);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth';
|
||||
import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch';
|
||||
import { warmDesktopHostStatuses } from '@openchamber/ui/lib/desktopHostStatus';
|
||||
import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore';
|
||||
import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot';
|
||||
import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
@@ -8,6 +9,9 @@ import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components
|
||||
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
|
||||
import { createWebAPIs } from './api';
|
||||
|
||||
// The switcher's statuses are warmed after boot settles, not during it.
|
||||
const HOST_STATUS_WARMUP_DELAY_MS = 3_000;
|
||||
|
||||
const sameOrigin = (left: string, right: string): boolean => {
|
||||
if (!left || !right) return false;
|
||||
try {
|
||||
@@ -93,5 +97,12 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst
|
||||
// subscribes to runtime-change events, so bind the SDK explicitly.
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
});
|
||||
// Learn every instance's reachability in the background, so the switcher opens
|
||||
// on real values instead of probing for the first time under the user's
|
||||
// cursor. After the endpoint is settled and past the app's own bootstrap:
|
||||
// this is unprompted work and the machine's network is busiest at launch.
|
||||
void desktopRelayRestoreReady.then(() => {
|
||||
window.setTimeout(() => { void warmDesktopHostStatuses().catch(() => {}); }, HOST_STATUS_WARMUP_DELAY_MS);
|
||||
});
|
||||
return createWebAPIs({ urls });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user