diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx index 135804bc8b575..58fb71b0f6afd 100644 --- a/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,29 +22,9 @@ vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local'), $connectionsRegistry: atom(null), $pendingConnectionId: atom(null), - initializeConnectionsRegistry: vi.fn(async () => null), - refreshConnectionsRegistry: vi.fn(async () => null), selectConnection: vi.fn(async () => undefined) })) -vi.mock('@/store/boot', () => ({ - $desktopBoot: atom({ - error: null, - fakeMode: false, - message: 'Starting', - phase: 'renderer.init', - progress: 2, - running: true, - timestamp: 0, - visible: true - }) -})) - -vi.mock('@/store/windows', () => ({ - isAuxiliaryWindow: vi.fn(() => false), - isPeerInstanceWindow: vi.fn(() => false) -})) - vi.mock('@/i18n', () => ({ useI18n: () => ({ t: { @@ -69,17 +49,10 @@ vi.mock('@/i18n', () => ({ })) const connectionStore = await import('@/store/connections') -const bootStore = await import('@/store/boot') -const windowStore = await import('@/store/windows') const $activeConnectionId = connectionStore.$activeConnectionId as ReturnType> const $connectionsRegistry = connectionStore.$connectionsRegistry -const $desktopBoot = bootStore.$desktopBoot const $pendingConnectionId = connectionStore.$pendingConnectionId -const initializeConnectionsRegistry = vi.mocked(connectionStore.initializeConnectionsRegistry) -const refreshConnectionsRegistry = vi.mocked(connectionStore.refreshConnectionsRegistry) const selectConnection = vi.mocked(connectionStore.selectConnection) -const isAuxiliaryWindow = vi.mocked(windowStore.isAuxiliaryWindow) -const isPeerInstanceWindow = vi.mocked(windowStore.isPeerInstanceWindow) const onConnect = vi.fn() const connection = (id: string, label: string, kind: 'local' | 'remote' = 'remote') => ({ @@ -102,73 +75,11 @@ afterEach(() => { vi.clearAllMocks() $connectionsRegistry.set(null) $activeConnectionId.set('local') - $desktopBoot.set({ - error: null, - fakeMode: false, - message: 'Starting', - phase: 'renderer.init', - progress: 2, - running: true, - timestamp: 0, - visible: true - }) $pendingConnectionId.set(null) $findInPage.set({ active: false, query: '', matchOrdinal: 0, matchCount: 0 }) - isAuxiliaryWindow.mockReturnValue(false) - isPeerInstanceWindow.mockReturnValue(false) }) describe('ConnectionSwitcher', () => { - it('waits for primary boot fetches before restoring the launch source', async () => { - $connectionsRegistry.set(registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')])) - render() - - expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1) - expect(initializeConnectionsRegistry).not.toHaveBeenCalled() - - $desktopBoot.set({ - ...$desktopBoot.get(), - phase: 'renderer.ready', - progress: 100, - running: false, - visible: false - }) - - await waitFor(() => expect(initializeConnectionsRegistry).toHaveBeenCalledTimes(1)) - }) - - it('keeps a full peer on the shared backend instead of replaying app-launch source restoration', async () => { - isPeerInstanceWindow.mockReturnValue(true) - $desktopBoot.set({ - ...$desktopBoot.get(), - phase: 'renderer.ready', - progress: 100, - running: false, - visible: false - }) - - render() - - await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1)) - expect(initializeConnectionsRegistry).not.toHaveBeenCalled() - }) - - it('keeps a secondary session window from replaying app-launch source restoration', async () => { - isAuxiliaryWindow.mockReturnValue(true) - $desktopBoot.set({ - ...$desktopBoot.get(), - phase: 'renderer.ready', - progress: 100, - running: false, - visible: false - }) - - render() - - await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1)) - expect(initializeConnectionsRegistry).not.toHaveBeenCalled() - }) - it('adds no source chrome for a local-only setup', () => { $connectionsRegistry.set(registry([connection('local', 'This device', 'local')])) render() @@ -363,13 +274,8 @@ describe('ConnectionSwitcher', () => { expect(screen.getByRole('group', { name: 'Registered gateways' }).getAttribute('aria-busy')).toBe('true') }) - // #95393: connections.save succeeded but the switcher kept painting the - // stale registry until reload. Mirrors the live repro (w2_95393.py): open - // the menu, save a new connection via the bridge, re-open the menu WITHOUT - // reload — the new row must be there. Electron now pushes a 'saved' - // onChanged for every successful save; the switcher's listener re-pulls the - // snapshot. - it('repaints the menu after a connections.save without reload (#95393)', async () => { + // The window lifecycle owns IPC; this consumer paints its published cache. + it('repaints the menu after the registry changes without reload', () => { const before = registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')]) const after = registry([ @@ -379,63 +285,17 @@ describe('ConnectionSwitcher', () => { ]) $connectionsRegistry.set(before) + render() - let onChangedCallback: ((payload: { connectionId: string; reason: string }) => void) | null = null - - ;(window as { hermesDesktop?: unknown }).hermesDesktop = { - connections: { - list: vi.fn(async () => after), - onChanged: vi.fn((callback: (payload: { connectionId: string; reason: string }) => void) => { - onChangedCallback = callback - - return () => { - onChangedCallback = null - } - }) - } - } - - // The real refreshConnectionsRegistry re-pulls list() and republishes the - // atom; the mock mirrors exactly that seam against Electron's current - // registry state (before the save, then after it). - let electronRegistry = before + const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) - refreshConnectionsRegistry.mockImplementation(async () => { - $connectionsRegistry.set(electronRegistry) + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + expect(screen.queryByRole('menuitemradio', { name: 'W2Probe' })).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) - return electronRegistry - }) + act(() => $connectionsRegistry.set(after)) - try { - render() - - const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) - - fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) - expect(screen.queryByRole('menuitemradio', { name: 'W2Probe' })).toBeNull() - fireEvent.keyDown(document, { key: 'Escape' }) - - // The save lands in Electron's registry… - electronRegistry = after - // …and Electron's post-save push (reason 'saved' — no dial change) is - // the ONLY signal this window gets. Pre-fix, save never emitted it. - expect(onChangedCallback).not.toBeNull() - ;(onChangedCallback as unknown as (payload: { connectionId: string; reason: string }) => void)({ - connectionId: 'w2-probe', - reason: 'saved' - }) - - await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(2)) - - fireEvent.pointerDown(screen.getByRole('button', { name: 'Registered gateways: This device' }), { - button: 0, - pointerType: 'mouse' - }) - expect(screen.getByRole('menuitemradio', { name: 'W2Probe' })).toBeTruthy() - } finally { - refreshConnectionsRegistry.mockReset() - refreshConnectionsRegistry.mockResolvedValue(null) - delete (window as { hermesDesktop?: unknown }).hermesDesktop - } + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + expect(screen.getByRole('menuitemradio', { name: 'W2Probe' })).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx index 563ded9a5268a..695310f66efd3 100644 --- a/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx @@ -25,18 +25,14 @@ import { import { triggerHaptic } from '@/lib/haptics' import { Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $desktopBoot } from '@/store/boot' import { $activeConnectionId, $connectionsRegistry, $pendingConnectionId, - initializeConnectionsRegistry, - refreshConnectionsRegistry, selectConnection } from '@/store/connections' import { closeFindBar } from '@/store/find-in-page' import { notifyError } from '@/store/notifications' -import { isAuxiliaryWindow, isPeerInstanceWindow } from '@/store/windows' import { ConnectionGlyph } from './connection-glyph' @@ -44,37 +40,12 @@ export function ConnectionSwitcher({ compact = false, onConnect }: { compact?: b const { t } = useI18n() const registry = useStore($connectionsRegistry) const activeConnectionId = useStore($activeConnectionId) - const boot = useStore($desktopBoot) const pendingConnectionId = useStore($pendingConnectionId) const [searchQuery, setSearchQuery] = useState('') const [menuOpen, setMenuOpen] = useState(false) const connectionListRef = useRef(null) const searchInputRef = useRef(null) - useEffect(() => { - void refreshConnectionsRegistry().catch(() => undefined) - - // Registry events are local IPC notifications, not remote polling. They - // keep a second Settings window or a removal/edit reflected here. - const off = window.hermesDesktop?.connections?.onChanged?.(() => { - void refreshConnectionsRegistry().catch(() => undefined) - }) - - return off - }, []) - - useEffect(() => { - // The primary boot owns its initial config/session fetches. Restoring a - // different source before those settle lets a late primary response repaint - // the sidebar under the new source label. Switch only after boot completes, - // then the normal source reset/refetch remains the final writer. Peer and - // auxiliary windows already boot into their intended runtime; replaying the - // primary window's app-launch preference would move them away from it. - if (!boot.running && !isAuxiliaryWindow() && !isPeerInstanceWindow()) { - void initializeConnectionsRegistry().catch(() => undefined) - } - }, [boot.running]) - const connections = useMemo(() => sortConnectionsForDisplay(registry?.connections ?? []), [registry?.connections]) const activeConnection = connections.find(connection => connection.id === activeConnectionId) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 0c24c06015d9c..48e56573a92ee 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -58,6 +58,7 @@ import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { isOnboardingEnabled } from '@/lib/onboarding-enabled' import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives' import { setYoloEnabled } from '@/lib/yolo-session' +import { $connectionsRegistry } from '@/store/connection-registry-state' import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode' import { $fileBrowserOpen, @@ -593,9 +594,9 @@ bindToolPaneCollapse( () => setTerminalTakeover(true), $showsAdvancedChrome ) -// Policies may consult the install: the profile rail stays in Simple when a -// second profile makes it the only remaining way to switch. +// Without the statusbar, the rail is the only way to switch profiles or gateways. $profiles.subscribe(profiles => setModeContext({ profileCount: profiles.length })) +$connectionsRegistry.subscribe(registry => setModeContext({ connectionCount: registry?.connections.length ?? 0 })) // ⌘K door onto the same pane the keybind and statusbar pill flip — was a // one-way "open" row under Go to, so it never showed on/off and couldn't hide. // Reads the TREE like every other pane toggle: `$terminalTakeover` stays true diff --git a/apps/desktop/src/app/gateway/hooks/use-connections-registry.test.tsx b/apps/desktop/src/app/gateway/hooks/use-connections-registry.test.tsx new file mode 100644 index 0000000000000..0be553dc3044e --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-connections-registry.test.tsx @@ -0,0 +1,90 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useConnectionsRegistry } from './use-connections-registry' + +vi.mock('@/store/connections', () => ({ + initializeConnectionsRegistry: vi.fn(async () => null), + refreshConnectionsRegistry: vi.fn(async () => null) +})) +vi.mock('@/store/boot', () => ({ $desktopBoot: atom({ running: true }) })) +vi.mock('@/store/windows', () => ({ + isAuxiliaryWindow: vi.fn(() => false), + isPeerInstanceWindow: vi.fn(() => false) +})) + +const { $desktopBoot } = await import('@/store/boot') +const connections = await import('@/store/connections') +const windows = await import('@/store/windows') +const refresh = vi.mocked(connections.refreshConnectionsRegistry) +const initialize = vi.mocked(connections.initializeConnectionsRegistry) + +beforeEach(() => { + vi.clearAllMocks() + refresh.mockResolvedValue(null) + $desktopBoot.set({ ...$desktopBoot.get(), running: true }) + vi.mocked(windows.isAuxiliaryWindow).mockReturnValue(false) + vi.mocked(windows.isPeerInstanceWindow).mockReturnValue(false) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('window-owned connection registry', () => { + it('waits for primary boot fetches before restoring the launch source', async () => { + renderHook(useConnectionsRegistry) + expect(refresh).toHaveBeenCalledTimes(1) + expect(initialize).not.toHaveBeenCalled() + + act(() => $desktopBoot.set({ ...$desktopBoot.get(), running: false })) + await waitFor(() => expect(initialize).toHaveBeenCalledTimes(1)) + }) + + it.each(['isPeerInstanceWindow', 'isAuxiliaryWindow'] as const)( + 'loads the cache without replaying app-launch preferences when %s', + async kind => { + vi.mocked(windows[kind]).mockReturnValue(true) + $desktopBoot.set({ ...$desktopBoot.get(), running: false }) + renderHook(useConnectionsRegistry) + + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)) + expect(initialize).not.toHaveBeenCalled() + } + ) + + it('bounds failed reads and recovers on focus without polling a healthy registry', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + refresh.mockRejectedValue(new Error('Registry IPC unavailable')) + const view = renderHook(useConnectionsRegistry) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(refresh).toHaveBeenCalledTimes(3) + expect(warn).toHaveBeenCalled() + + refresh.mockResolvedValue(null) + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) + expect(refresh).toHaveBeenCalledTimes(4) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(refresh).toHaveBeenCalledTimes(4) + + view.unmount() + await act(async () => { + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(refresh).toHaveBeenCalledTimes(4) + }) +}) diff --git a/apps/desktop/src/app/gateway/hooks/use-connections-registry.ts b/apps/desktop/src/app/gateway/hooks/use-connections-registry.ts new file mode 100644 index 0000000000000..34564d78cefd8 --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-connections-registry.ts @@ -0,0 +1,84 @@ +import { useStore } from '@nanostores/react' +import { useEffect } from 'react' + +import { $desktopBoot } from '@/store/boot' +import { initializeConnectionsRegistry, refreshConnectionsRegistry } from '@/store/connections' +import { isAuxiliaryWindow, isPeerInstanceWindow } from '@/store/windows' + +const RETRY_DELAYS_MS = [1_000, 2_000] + +/** Gateway discovery belongs to the window, not its optional navigation chrome. */ +export function useConnectionsRegistry(): void { + const boot = useStore($desktopBoot) + + useEffect(() => { + let disposed = false + let failed = false + let pending = false + let requested = false + let retries = 0 + let timer: ReturnType | undefined + + const refresh = async () => { + if (pending) { + requested = true + + return + } + + clearTimeout(timer) + pending = true + + try { + await refreshConnectionsRegistry() + failed = false + retries = 0 + } catch (error) { + failed = true + + if (!disposed) { + if (retries < RETRY_DELAYS_MS.length) { + timer = setTimeout(() => void refresh(), RETRY_DELAYS_MS[retries++]) + } else { + console.warn('[connections] Registry read failed; retrying on focus or registry change', error) + } + } + } finally { + pending = false + + // A save may have landed while the previous snapshot was in flight. + if (!disposed && requested) { + requested = false + void refresh() + } + } + } + + const onFocus = () => { + if (failed && !pending) { + retries = 0 + void refresh() + } + } + + const off = window.hermesDesktop?.connections?.onChanged?.(() => void refresh()) + window.addEventListener('focus', onFocus) + void refresh() + + return () => { + disposed = true + clearTimeout(timer) + window.removeEventListener('focus', onFocus) + off?.() + } + }, []) + + useEffect(() => { + // Finish the primary config/session reads before restoring another source, + // or their late responses could repaint the new workspace. Peer and + // auxiliary windows already have an explicit destination of their own. + if (!boot.running && !isAuxiliaryWindow() && !isPeerInstanceWindow()) { + void initializeConnectionsRegistry().catch(() => undefined) + } + }, [boot.running]) +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 11d2ea69bbde9..f37f7cbdde319 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -374,6 +374,75 @@ async function flushAsync() { }) } +it('loads and tracks saved gateways without mounting the statusbar or Settings', async () => { + const desktop = fakeDesktop() + const bootFetch = deferred() + type Listener = Parameters>[0] + const listeners = new Set() + let registry: DesktopConnectionsRegistry = { + version: 2, + primary: primaryConn.connectionId, + secureTokenStorage: true, + connections: [ + { id: primaryConn.connectionId, kind: 'remote', label: 'Primary', tokenPreview: null, tokenSet: false }, + { id: coderConn.connectionId, kind: 'remote', label: 'Coder', tokenPreview: null, tokenSet: false } + ] + } + const list = vi.fn(async () => registry) + const setLastUsed = vi.fn(async () => ({ ok: true, registry })) + + Object.assign(desktop, { + connections: { + list, + setLastUsed, + onChanged: (callback: Listener) => { + listeners.add(callback) + + return () => listeners.delete(callback) + } + } + }) + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + // Only the real gateway lifecycle mounts; no optional UI can load the cache. + const view = render( bootFetch.promise} />) + await flushAsync() + + expect($connectionsRegistry.get()).toEqual(registry) + expect($desktopBoot.get().running).toBe(true) + expect(setLastUsed).not.toHaveBeenCalled() + + bootFetch.resolve() + await flushAsync() + expect($desktopBoot.get().running).toBe(false) + expect(setLastUsed).toHaveBeenCalledExactlyOnceWith(primaryConn.connectionId) + + const activeConnection = $connection.get() + registry = { + ...registry, + connections: registry.connections.map(entry => ({ ...entry, label: `${entry.label} renamed` })) + } + await act(async () => { + for (const callback of listeners) { + callback({ connectionId: coderConn.connectionId, reason: 'saved' }) + } + }) + expect($connectionsRegistry.get()).toEqual(registry) + + registry = { ...registry, connections: registry.connections.slice(0, 1) } + await act(async () => { + for (const callback of listeners) { + callback({ connectionId: coderConn.connectionId, reason: 'removed' }) + } + }) + expect($connectionsRegistry.get()).toEqual(registry) + expect($connection.get()).toBe(activeConnection) + expect(setLastUsed).toHaveBeenCalledTimes(1) + + view.unmount() + expect(listeners.size).toBe(0) +}) + // Drive the exponential backoff forward by its full cap so the next scheduled // reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the // attempt's async work settles. diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 38b0dc4459660..74666783d2fbd 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -103,6 +103,7 @@ import { warnIfTerminalBackendUnavailable } from '@/store/terminal-backend-warni import { isPeerInstanceWindow, windowProfileOverride } from '@/store/windows' import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' +import { useConnectionsRegistry } from './use-connections-registry' import { useDefaultProfilePreference } from './use-default-profile-preference' // After the reconnect loop has been failing for this long, raise a NON-blocking @@ -185,6 +186,7 @@ export function useGatewayBoot({ refreshSessions }: GatewayBootOptions) { useDefaultProfilePreference() + useConnectionsRegistry() const callbacksRef = useRef({ beforeConnectionSwitch, @@ -1128,8 +1130,7 @@ export function useGatewayBoot({ // 'saved' is a pure registry-refresh push (new connection or label // rename — #95393): no endpoint moved, so there is nothing to dispose, - // redial, or forget. The switcher's own onChanged listener re-pulls the - // registry snapshot for it. + // redial, or forget. useConnectionsRegistry re-pulls the snapshot. if (payload.reason === 'saved') { return } diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx index f93c63b51be59..8ccbdd1355fa8 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx @@ -151,7 +151,7 @@ describe('CustomEndpointsSettings', () => { await waitFor(() => expect(getCustomEndpoints).toHaveBeenCalledWith('content-studio')) expect(screen.getByText('Applies to')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('Axet Proxy'), { target: { value: 'Studio gateway' } }) + fireEvent.change(await screen.findByPlaceholderText('Axet Proxy'), { target: { value: 'Studio gateway' } }) fireEvent.change(screen.getByPlaceholderText('http://127.0.0.1:8081/v1'), { target: { value: 'https://studio.example.com/v1' } }) diff --git a/apps/desktop/src/store/connections.test.ts b/apps/desktop/src/store/connections.test.ts index a9b579b1e7145..16ed26ccda1ec 100644 --- a/apps/desktop/src/store/connections.test.ts +++ b/apps/desktop/src/store/connections.test.ts @@ -166,6 +166,24 @@ describe('connection registry cache', () => { expect(list).toHaveBeenCalledTimes(1) expect($connectionsRegistry.get()).toEqual(registry) expect($activeConnectionId.get()).toBeNull() + + // A stuck IPC read must release the lifecycle's retry loop and preserve + // the last good cache, even if the timed-out snapshot eventually arrives. + vi.useFakeTimers() + const stuck = deferred() + list.mockImplementationOnce(() => stuck.promise) + + try { + const refresh = refreshConnectionsRegistry() + const rejected = expect(refresh).rejects.toThrow('Timed out reading the connection registry') + await vi.advanceTimersByTimeAsync(5_000) + await rejected + stuck.resolve({ ...registry, connections: [] }) + await Promise.resolve() + expect($connectionsRegistry.get()).toEqual(registry) + } finally { + vi.useRealTimers() + } }) it('restores the last-used source once when that launch mode is enabled', async () => { diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 0a0809127a9ef..30fbd2ba6bcf0 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -42,6 +42,7 @@ const SWITCH_REMEMBER_TIMEOUT_MS = 5_000 // restore should stop waiting for it. Shared constant so the boot-class // budgets can't drift apart (see with-timeout.ts). const BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS = BACKEND_BOOT_WAIT_TIMEOUT_MS +const REGISTRY_READ_TIMEOUT_MS = 5_000 export { $connectionsRegistry } from '@/store/connection-registry-state' @@ -127,7 +128,11 @@ export async function refreshConnectionsRegistry(): Promise { expect($bound.get()).toBe('technical') }) - it('keeps the profile door open in Simple when there is a second profile to walk through', async () => { + it('keeps the profile rail available in Simple for multiple profiles or gateways', async () => { const { modeBound, setInterfaceMode, setModeContext } = await loadStore() const $pref = atom(true) const $bound = modeBound('profileRailVisible', $pref, value => $pref.set(value)) @@ -136,6 +136,12 @@ describe('modeBound resolver', () => { setModeContext({ profileCount: 1 }) expect($bound.get()).toBe(false) + + setModeContext({ connectionCount: 2 }) + expect($bound.get()).toBe(true) + + setModeContext({ connectionCount: 1 }) + expect($bound.get()).toBe(false) }) }) diff --git a/apps/desktop/src/store/interface-mode.ts b/apps/desktop/src/store/interface-mode.ts index df59f03e3f229..cba772523dd8e 100644 --- a/apps/desktop/src/store/interface-mode.ts +++ b/apps/desktop/src/store/interface-mode.ts @@ -76,6 +76,7 @@ export function toggleSimpleMode() { /** What a policy may look at when its answer depends on the install. */ export interface ModeContext { + connectionCount: number profileCount: number } @@ -102,10 +103,9 @@ const SIMPLE_POLICY: PolicyTable = { fileBrowserOpen: false, // Inline diffs are the review pane's job; the changed-files summary stays. hideCodeDiffs: true, - // Hide a door only when there is somewhere else to go: with the statusbar - // (and its fallback profile dropdown) gone, a second profile makes the rail - // the only way to switch, so it stays for multi-profile installs. - profileRailVisible: context => context.profileCount > 1, + // Without the statusbar, multi-gateway installs still need the rail even + // when the active gateway has only one profile. + profileRailVisible: context => context.profileCount > 1 || context.connectionCount > 1, // A quiet "Thought for Ns" row instead of a live reasoning stream. reasoningCollapsedByDefault: true, reviewOpen: false, @@ -127,10 +127,10 @@ const POLICY: Record = { /** Does this mode have an opinion about the surface at all? */ const shadows = (key: ModePolicyKey, mode: InterfaceMode) => key in POLICY[mode] -// The install facts a policy may consult. Fed by the app shell (profile roster), +// The install facts a policy may consult. Fed by the app shell (profiles and gateways), // kept off this module's imports so preference stores can depend on it without // dragging the session graph in. -export const $modeContext = atom({ profileCount: 1 }) +export const $modeContext = atom({ connectionCount: 1, profileCount: 1 }) export function setModeContext(patch: Partial) { $modeContext.set({ ...$modeContext.get(), ...patch }) diff --git a/website/docs/user-guide/multi-connection-desktop.md b/website/docs/user-guide/multi-connection-desktop.md index d1c465309f7de..d636f78e6debf 100644 --- a/website/docs/user-guide/multi-connection-desktop.md +++ b/website/docs/user-guide/multi-connection-desktop.md @@ -251,9 +251,12 @@ that live on one gateway. are all scoped to the active `(gateway, profile)`. Switching from a Telegram gateway to a Signal gateway cannot leave the previous gateway's channel groups or sessions in the sidebar. -- Merely displaying the switcher reads Electron's local connection registry. - Remote gateways are opened only when selected; there is no periodic fleet - polling. +- Desktop loads the local connection registry at startup, even with the status + bar hidden, and keeps it current when gateways are saved or removed. Opening + Settings is not required. In Simple mode, the profile rail stays available + when more than one gateway is registered, even with only a default profile. + Loading the registry does not connect every gateway; there is no periodic + fleet polling. - Hovering an agent pre-warms its backend so the switch doesn't pay a cold boot. SSH agents are the exception: hovering never dials the tunnel or starts a remote backend — only opening one does.