diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8cd3022c..92a34faf 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 = {}; +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([]); const [defaultHostId, setDefaultHostId] = React.useState(null); - const [statusById, setStatusById] = React.useState>(() => ({ ...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(null); const [sshHostIds, setSshHostIds] = React.useState>({}); @@ -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 => { - 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 = {}; - 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 diff --git a/packages/ui/src/lib/desktopHostStatus.test.ts b/packages/ui/src/lib/desktopHostStatus.test.ts new file mode 100644 index 00000000..d2fa63a9 --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { DesktopHost, HostProbeResult } from './desktopHosts'; + +let probeResults: Record = {}; +const probeCalls: string[] = []; +let probeGate: Promise | 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 = []; + 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((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); + }); +}); diff --git a/packages/ui/src/lib/desktopHostStatus.ts b/packages/ui/src/lib/desktopHostStatus.ts new file mode 100644 index 00000000..303f1c5a --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.ts @@ -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; + +type DesktopHostStatusSnapshot = { + byHostId: Readonly; + /** 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(); +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 => { + if (!isElectronShell()) return ''; + return desktopLocalClientTokenGet().catch(() => ''); +}; + +const probeHost = async (host: DesktopHost, localClientToken: string): Promise => { + const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || ''); + const probeRelayLeg = async (): Promise => { + 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 => { + 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 => { + 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]); +}; diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 4d45efa8..d6a968f3 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -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 }); };