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
162 changes: 11 additions & 151 deletions apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -22,29 +22,9 @@ vi.mock('@/store/connections', () => ({
$activeConnectionId: atom<null | string>('local'),
$connectionsRegistry: atom<DesktopConnectionsRegistry | null>(null),
$pendingConnectionId: atom<null | string>(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: {
Expand All @@ -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<typeof atom<null | string>>
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') => ({
Expand All @@ -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(<ConnectionSwitcher onConnect={onConnect} />)

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(<ConnectionSwitcher onConnect={onConnect} />)

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(<ConnectionSwitcher onConnect={onConnect} />)

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(<ConnectionSwitcher onConnect={onConnect} />)
Expand Down Expand Up @@ -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([
Expand All @@ -379,63 +285,17 @@ describe('ConnectionSwitcher', () => {
])

$connectionsRegistry.set(before)
render(<ConnectionSwitcher onConnect={onConnect} />)

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(<ConnectionSwitcher onConnect={onConnect} />)

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()
})
})
29 changes: 0 additions & 29 deletions apps/desktop/src/app/chat/sidebar/connection-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,56 +25,27 @@ 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'

export function ConnectionSwitcher({ compact = false, onConnect }: { compact?: boolean; onConnect: () => void }) {
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<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(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)
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/app/contrib/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading