fix(desktop): stabilize gateway settings interactions

This commit is contained in:
David Dudok de Wit
2026-08-18 20:28:43 +02:00
committed by Teknium
parent c41ec9c588
commit 9ed738fa0c
6 changed files with 158 additions and 52 deletions
@@ -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(<ConnectionsRegistrySection />)
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(<ConnectionsRegistrySection />)
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(<ConnectionsRegistrySection />)
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(<ConnectionsRegistrySection />)
render(
<div data-testid="settings-scroller" style={{ height: 400, overflowY: 'auto' }}>
<ConnectionsRegistrySection />
</div>
)
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<HTMLElement>('.border-t')?.style.minHeight).toBe('')
})
it('tests a connection through the bridge', async () => {
@@ -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<DesktopConnectionKind, typeof Globe> = {
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<HTMLInputElement>(null)
const pendingSearchTopRef = useRef<null | number>(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 | string>(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}
</p>
{!loading && registry && registry.connections.length > 1 && (
<ListRow
action={
<SegmentedControl
disabled={launchModeBusy || !bridge?.setLaunchMode}
onChange={mode => 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 && (
<Input
aria-label={s.searchPlaceholder}
containerClassName="mb-3 w-full max-w-sm"
onChange={event => setSearchQuery(event.target.value)}
containerClassName="mt-3 mb-0 w-full max-w-sm"
onChange={event => updateSearchQuery(event.target.value)}
placeholder={s.searchPlaceholder}
prefix={<SearchIcon className="size-3.5" />}
ref={searchInputRef}
size="sm"
type="search"
value={searchQuery}
@@ -825,6 +851,18 @@ export function ConnectionsRegistrySection() {
</div>
)}
{!loading && registry && registry.connections.length > 1 && (
<div className="mt-6 border-t border-border/60 pt-4">
<ToggleRow
checked={registry.launchMode === 'last-used'}
description={s.launchModeDesc}
disabled={launchModeBusy || !bridge?.setLaunchMode}
label={s.launchModeTitle}
onChange={enabled => void setLaunchMode(enabled ? 'last-used' : 'primary')}
/>
</div>
)}
<ConfirmDialog
confirmLabel={s.removeConnection}
description={removeTarget ? s.removeConfirmDesc(removeTarget.label) : ''}
+2 -5
View File
@@ -705,11 +705,8 @@ export const en: Translations = {
intro: 'Manage this device and every Hermes gateway it can reach through remote, SSH, or Cloud connections.',
stagedNote:
'Switch gateways from Sessions. Profiles, chats, messaging, and cron jobs stay with their gateway; work on other gateways keeps running.',
launchModeTitle: 'Open on launch',
launchModeDesc:
'Choose whether Hermes opens your primary gateway or the last gateway that connected successfully.',
launchPrimary: 'Primary gateway',
launchLastUsed: 'Last used',
launchModeTitle: 'At startup, return to Sessions on the last-used gateway',
launchModeDesc: 'When off, Sessions opens on the Primary gateway.',
searchPlaceholder: 'Search gateways…',
noSearchResults: 'No gateways match your search.',
loadFailed: 'Could not load connections',
-2
View File
@@ -594,8 +594,6 @@ export interface Translations {
stagedNote: string
launchModeTitle: string
launchModeDesc: string
launchPrimary: string
launchLastUsed: string
searchPlaceholder: string
noSearchResults: string
loadFailed: string
+2 -4
View File
@@ -910,10 +910,8 @@ export const zh: Translations = {
intro: '管理本机以及通过远程、SSH 或 Hermes Cloud 连接可访问的每个 Hermes 网关。',
stagedNote:
'可在“会话”侧边栏切换网关。配置档案、聊天、消息和定时任务归属于各自网关;其他网关上的工作会继续运行。',
launchModeTitle: '启动时打开',
launchModeDesc: '选择 Hermes 启动时打开主网关,或上次成功连接的网关。',
launchPrimary: '主网关',
launchLastUsed: '上次使用',
launchModeTitle: '启动时返回上次使用网关的会话视图',
launchModeDesc: '关闭时,会话将在主网关上打开。',
searchPlaceholder: '搜索网关…',
noSearchResults: '没有与搜索匹配的网关。',
loadFailed: '无法加载连接',
@@ -58,12 +58,11 @@ Rules worth knowing:
registry fallback for multi-gateway calls that do not name a gateway.
**Make primary** does not switch the current Sessions workspace; removing
the primary falls back to the local entry.
- **Open on launch** controls which gateway Sessions opens after a full app
restart. **Primary gateway** preserves the established Desktop behavior and
remains the default for existing and new installs. Choose **Last used** to
resume the most recent gateway that connected successfully. A failed switch
is never remembered, and a removed or unavailable saved gateway falls back
to Primary.
- **At startup, return to Sessions on the last-used gateway** controls which
gateway Sessions opens after a full app restart. It is off by default, so
Sessions opens on **Primary**. Turn it on to resume the most recent gateway
that connected successfully. A failed switch is never remembered, and a
removed or unavailable saved gateway falls back to Primary.
- **Test** probes the connection's own HTTP *and* WebSocket legs, so a pass
(the *"Reachable"* toast) means chat will actually work — not just that the
host pinged.
@@ -176,9 +175,9 @@ that live on one gateway.
profile and the layers pill shows **All profiles on this gateway**.
**Cmd/Ctrl+1–9** continue to switch profiles within the active gateway.
- The selected gateway survives a quit and relaunch only when **Settings →
Gateways → Open on launch** is set to **Last used**. The preference and
gateway id live in the app's user-data registry, so replacing or updating the
application bundle does not reset them.
Gateways → At startup, return to Sessions on the last-used gateway** is on.
The preference and gateway id live in the app's user-data registry, so
replacing or updating the application bundle does not reset them.
- With more than thirteen profiles on the active gateway, their avatar strip
condenses into a named profile selector. Large gateway and profile sets can
therefore coexist without changing the **gateway → profile → sessions** model.