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')} + /> +
+ )} +