From 9ed738fa0c638c20e8dc5041fbdf045e503deb35 Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Tue, 18 Aug 2026 20:28:43 +0200
Subject: [PATCH] fix(desktop): stabilize gateway settings interactions
---
.../settings/connections-registry.test.tsx | 94 +++++++++++++++++--
.../src/app/settings/connections-registry.tsx | 84 ++++++++++++-----
apps/desktop/src/i18n/en.ts | 7 +-
apps/desktop/src/i18n/types.ts | 2 -
apps/desktop/src/i18n/zh.ts | 6 +-
.../user-guide/multi-connection-desktop.md | 17 ++--
6 files changed, 158 insertions(+), 52 deletions(-)
diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx
index 60a26e1a68..dcc4c0b2ea 100644
--- a/apps/desktop/src/app/settings/connections-registry.test.tsx
+++ b/apps/desktop/src/app/settings/connections-registry.test.tsx
@@ -75,7 +75,7 @@ describe('ConnectionsRegistrySection', () => {
// Label and the managed pill share the copy, so expect both instances.
expect(screen.getAllByText('This device').length).toBeGreaterThan(0)
expect(screen.getByText('Current')).toBeTruthy()
- expect(screen.getByText('Primary')).toBeTruthy()
+ expect(screen.getAllByText('Primary').length).toBeGreaterThan(0)
expect(list).toHaveBeenCalledTimes(1)
})
@@ -136,11 +136,15 @@ describe('ConnectionsRegistrySection', () => {
expect(save).not.toHaveBeenCalled()
})
- it('makes a non-primary connection primary', async () => {
+ it('keeps the primary fallback configurable while last-used restore is enabled', async () => {
+ list.mockResolvedValueOnce({ ...registry, launchMode: 'last-used' })
render()
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
- fireEvent.click(screen.getByText('Make primary'))
+ const makePrimary = screen.getByText('Make primary').closest('button')!
+
+ expect(makePrimary.disabled).toBe(false)
+ fireEvent.click(makePrimary)
await waitFor(() => expect(setPrimary).toHaveBeenCalledWith('homelab'))
})
@@ -148,8 +152,11 @@ describe('ConnectionsRegistrySection', () => {
it('lets users opt into restoring the last-used source', async () => {
render()
- await waitFor(() => expect(screen.getByText('Open on launch')).toBeTruthy())
- fireEvent.click(screen.getByText('Last used'))
+ const launchSetting = await screen.findByText('At startup, return to Sessions on the last-used gateway')
+ const addConnection = screen.getByText('Add connection')
+
+ expect(addConnection.compareDocumentPosition(launchSetting) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ fireEvent.click(screen.getByRole('switch', { name: 'At startup, return to Sessions on the last-used gateway' }))
await waitFor(() => expect(setLaunchMode).toHaveBeenCalledWith('last-used'))
})
@@ -160,7 +167,7 @@ describe('ConnectionsRegistrySection', () => {
render()
await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
- expect(screen.queryByText('Open on launch')).toBeNull()
+ expect(screen.queryByText('At startup, return to Sessions on the last-used gateway')).toBeNull()
})
it('keeps search out of the way for a small registry', async () => {
@@ -191,23 +198,92 @@ describe('ConnectionsRegistrySection', () => {
label: index === 0 ? 'Alpha' : `Gateway ${index}`,
tokenPreview: null,
tokenSet: false,
- url: index === 4 ? 'https://studio.example.test' : `https://gateway-${index}.example.test`
+ url:
+ index === 4
+ ? 'https://studio.example.test'
+ : index === 5
+ ? 'https://studio-archive.example.test'
+ : `https://gateway-${index}.example.test`
}))
]
}
list.mockResolvedValueOnce(largeRegistry)
- render()
+ render(
+
+
+
+ )
const search = await screen.findByRole('searchbox', { name: 'Search gateways…' })
+ expect(search.parentElement?.className).toContain('mt-3')
+ expect(search.parentElement?.className).toContain('mb-0')
+ const settingsScroller = screen.getByTestId('settings-scroller')
+ settingsScroller.scrollTop = 200
+ vi.spyOn(search, 'getBoundingClientRect')
+ .mockReturnValueOnce({
+ bottom: 152,
+ height: 32,
+ left: 0,
+ right: 0,
+ top: 120,
+ width: 0,
+ x: 0,
+ y: 120,
+ toJSON: () => ({})
+ })
+ .mockReturnValueOnce({
+ bottom: 152,
+ height: 32,
+ left: 0,
+ right: 0,
+ top: 120,
+ width: 0,
+ x: 0,
+ y: 120,
+ toJSON: () => ({})
+ })
+ .mockReturnValueOnce({
+ bottom: 152,
+ height: 32,
+ left: 0,
+ right: 0,
+ top: 120,
+ width: 0,
+ x: 0,
+ y: 120,
+ toJSON: () => ({})
+ })
+ .mockReturnValue({
+ bottom: 182,
+ height: 32,
+ left: 0,
+ right: 0,
+ top: 150,
+ width: 0,
+ x: 0,
+ y: 150,
+ toJSON: () => ({})
+ })
const alpha = screen.getByText('Alpha')
const zulu = screen.getByText('Zulu')
expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
- fireEvent.change(search, { target: { value: 'studio.example' } })
+ fireEvent.change(search, { target: { value: 'studio' } })
+ expect(settingsScroller.scrollTop).toBe(200)
expect(screen.getByText('Gateway 4')).toBeTruthy()
+ expect(screen.getByText('Gateway 5')).toBeTruthy()
expect(screen.queryByText('Alpha')).toBeNull()
+
+ settingsScroller.scrollTop = 260
+ fireEvent.change(search, { target: { value: 'studio.example' } })
+ expect(settingsScroller.scrollTop).toBe(290)
+ expect(screen.getByText('Gateway 4')).toBeTruthy()
+ expect(screen.queryByText('Gateway 5')).toBeNull()
+
+ fireEvent.change(search, { target: { value: '' } })
+ expect(search.closest('.border-t')?.style.minHeight).toBe('')
})
it('tests a connection through the bridge', async () => {
diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx
index 7be9c30442..131f21a3b6 100644
--- a/apps/desktop/src/app/settings/connections-registry.tsx
+++ b/apps/desktop/src/app/settings/connections-registry.tsx
@@ -1,10 +1,9 @@
import { useStore } from '@nanostores/react'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Input } from '@/components/ui/input'
-import { SegmentedControl } from '@/components/ui/segmented-control'
import type {
DesktopConnectionKind,
DesktopConnectionsRegistry,
@@ -22,7 +21,7 @@ import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Te
import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections'
import { notify, notifyError } from '@/store/notifications'
-import { EmptyState, ListRow, Pill, SectionHeading } from './primitives'
+import { EmptyState, ListRow, Pill, SectionHeading, ToggleRow } from './primitives'
const KIND_ICONS: Record = {
cloud: Cloud,
@@ -199,6 +198,20 @@ export function sameBackendPeerLabel(
return null
}
+function scrollableAncestor(element: HTMLElement): HTMLElement | null {
+ let parent = element.parentElement
+
+ while (parent) {
+ if (/(auto|scroll)/.test(window.getComputedStyle(parent).overflowY)) {
+ return parent
+ }
+
+ parent = parent.parentElement
+ }
+
+ return null
+}
+
/**
* The connections registry section of Settings → Gateways: manage the named
* agent sources (local runtime + any number of remote gateways / Hermes Cloud
@@ -220,6 +233,8 @@ export function ConnectionsRegistrySection() {
const [launchModeBusy, setLaunchModeBusy] = useState(false)
const [updatingAll, setUpdatingAll] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
+ const searchInputRef = useRef(null)
+ const pendingSearchTopRef = useRef(null)
// Inline duplicate rejection from the save path (dedupe is also enforced in
// the main process, so a crafted payload can't slip past the UI check).
const [dupeError, setDupeError] = useState(null)
@@ -486,6 +501,34 @@ export function ConnectionsRegistrySection() {
connectionMatchesQuery(connection, effectiveSearchQuery, [kindMeta[connection.kind].label])
)
+ useLayoutEffect(() => {
+ const previousTop = pendingSearchTopRef.current
+ const input = searchInputRef.current
+
+ pendingSearchTopRef.current = null
+
+ if (previousTop == null || !input) {
+ return
+ }
+
+ const scroller = scrollableAncestor(input)
+
+ if (!scroller) {
+ return
+ }
+
+ const delta = input.getBoundingClientRect().top - previousTop
+
+ if (Math.abs(delta) > 0.5) {
+ scroller.scrollTop += delta
+ }
+ }, [displayedConnections.length, effectiveSearchQuery])
+
+ const updateSearchQuery = (nextQuery: string) => {
+ pendingSearchTopRef.current = searchInputRef.current?.getBoundingClientRect().top ?? null
+ setSearchQuery(nextQuery)
+ }
+
if (!bridge) {
return null
}
@@ -500,31 +543,14 @@ export function ConnectionsRegistrySection() {
{s.stagedNote}
- {!loading && registry && registry.connections.length > 1 && (
- void setLaunchMode(mode)}
- options={[
- { id: 'primary', label: s.launchPrimary },
- { id: 'last-used', label: s.launchLastUsed }
- ]}
- value={registry.launchMode ?? 'primary'}
- />
- }
- description={s.launchModeDesc}
- title={s.launchModeTitle}
- />
- )}
-
{!loading && showSearch && (
setSearchQuery(event.target.value)}
+ containerClassName="mt-3 mb-0 w-full max-w-sm"
+ onChange={event => updateSearchQuery(event.target.value)}
placeholder={s.searchPlaceholder}
prefix={}
+ ref={searchInputRef}
size="sm"
type="search"
value={searchQuery}
@@ -825,6 +851,18 @@ export function ConnectionsRegistrySection() {
)}
+ {!loading && registry && registry.connections.length > 1 && (
+
+ void setLaunchMode(enabled ? 'last-used' : 'primary')}
+ />
+
+ )}
+