fix(desktop): stabilize gateway settings interactions
This commit is contained in:
committed by
Teknium
parent
c41ec9c588
commit
9ed738fa0c
@@ -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) : ''}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -594,8 +594,6 @@ export interface Translations {
|
||||
stagedNote: string
|
||||
launchModeTitle: string
|
||||
launchModeDesc: string
|
||||
launchPrimary: string
|
||||
launchLastUsed: string
|
||||
searchPlaceholder: string
|
||||
noSearchResults: string
|
||||
loadFailed: string
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user