fix(desktop): reuse saved Cloud gateways from Settings
Separate saved Cloud instances from the live window source, use the existing registry activation path instead of repeating sign-in/apply, and persist the chosen instance name while retaining registry identity and custom labels. Naming metadata adapted from IAvecilla's contribution in PR #103224. Co-authored-by: IAvecilla <ignacio.avecilla@lambdaclass.com>
This commit is contained in:
@@ -48,6 +48,70 @@ function emptyRegistry(): ConnectionRegistry {
|
||||
return normalizeRegistry(null)
|
||||
}
|
||||
|
||||
test('Cloud apply upgrades only host labels without changing connection identity', () => {
|
||||
const url = 'https://agent.example.com'
|
||||
const name = 'Research cloud'
|
||||
|
||||
const first = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
||||
mode: 'cloud',
|
||||
remote: { url, authMode: 'oauth' }
|
||||
})
|
||||
|
||||
const named = reconcileAppliedGlobalConnection(first, {
|
||||
mode: 'cloud',
|
||||
remote: { url, authMode: 'oauth', name }
|
||||
})
|
||||
|
||||
assert.equal(named.primary, first.primary)
|
||||
assert.equal(named.connections.find(c => c.id === named.primary)?.label, name)
|
||||
const restored = normalizeRegistry(JSON.parse(JSON.stringify(named)))
|
||||
assert.equal(restored.connections.find(c => c.id === named.primary)?.name, name)
|
||||
|
||||
const custom = upsertConnection(restored, {
|
||||
...restored.connections.find(c => c.id === named.primary)!,
|
||||
label: 'My device'
|
||||
})
|
||||
|
||||
const reapplied = reconcileAppliedGlobalConnection(custom, {
|
||||
mode: 'cloud',
|
||||
remote: { url, authMode: 'oauth', name: 'New portal name' }
|
||||
})
|
||||
|
||||
assert.equal(reapplied.primary, first.primary)
|
||||
assert.equal(reapplied.connections.find(c => c.id === first.primary)?.label, 'My device')
|
||||
|
||||
const other = reconcileAppliedGlobalConnection(reapplied, {
|
||||
mode: 'cloud',
|
||||
remote: { url: 'https://other.example.com', authMode: 'oauth' }
|
||||
})
|
||||
|
||||
assert.notEqual(other.primary, first.primary)
|
||||
assert.equal(other.connections.find(c => c.id === other.primary)?.name, undefined)
|
||||
})
|
||||
|
||||
test('Cloud name survives partial edits but never inherits across gateway URLs', () => {
|
||||
const registry = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
||||
mode: 'cloud',
|
||||
remote: { url: 'https://agent.example.com', authMode: 'oauth', name: 'Research cloud' }
|
||||
})
|
||||
|
||||
const existing = registry.connections.find(c => c.id === registry.primary)!
|
||||
|
||||
const renamed = normalizeConnectionInput(
|
||||
mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine' }, existing),
|
||||
registry
|
||||
)
|
||||
|
||||
assert.equal(renamed.name, 'Research cloud')
|
||||
|
||||
const retargeted = normalizeConnectionInput(
|
||||
mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine', url: 'https://other.example.com' }, existing),
|
||||
registry
|
||||
)
|
||||
|
||||
assert.equal(retargeted.name, undefined)
|
||||
})
|
||||
|
||||
// --- labels, slugs, handles ---
|
||||
|
||||
test('labelKey is case-insensitive and trimmed', () => {
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface RegistryConnection {
|
||||
headers?: Record<string, unknown>
|
||||
/** cloud: portal org slug/id the instance was discovered under. */
|
||||
org?: string
|
||||
/** Cloud instance name, separate from the user-editable label. */
|
||||
name?: string
|
||||
/** ssh fields (normalizeSshConfig shapes). */
|
||||
host?: string
|
||||
user?: string
|
||||
@@ -822,6 +824,8 @@ export interface ConnectionInput {
|
||||
token?: unknown
|
||||
headers?: Record<string, unknown>
|
||||
org?: string
|
||||
/** Cloud instance name, separate from the user-editable label. */
|
||||
name?: string
|
||||
host?: string
|
||||
user?: string
|
||||
port?: number | string
|
||||
@@ -958,6 +962,12 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne
|
||||
}
|
||||
}
|
||||
|
||||
const name = String(input.name || '').trim()
|
||||
|
||||
if (kind === 'cloud' && name) {
|
||||
entry.name = name
|
||||
}
|
||||
|
||||
const org = String(input.org || '').trim()
|
||||
|
||||
if (kind === 'cloud' && org) {
|
||||
@@ -995,6 +1005,14 @@ export function mergeConnectionInput(input: ConnectionInput, existing?: null | R
|
||||
inherit('url')
|
||||
inherit('authMode')
|
||||
inherit('org')
|
||||
|
||||
if (
|
||||
input.kind === 'cloud' &&
|
||||
(input.url === undefined || normalizeRemoteBaseUrl(input.url) === normalizeRemoteBaseUrl(existing.url))
|
||||
) {
|
||||
inherit('name')
|
||||
}
|
||||
|
||||
inherit('host')
|
||||
inherit('keyPath')
|
||||
inherit('remoteHermesPath')
|
||||
@@ -1184,6 +1202,12 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry {
|
||||
clean.headers = storedHeaders
|
||||
}
|
||||
|
||||
const name = String(entry.name || '').trim()
|
||||
|
||||
if (kind === 'cloud' && name) {
|
||||
clean.name = name
|
||||
}
|
||||
|
||||
const org = String(entry.org || '').trim()
|
||||
|
||||
if (kind === 'cloud' && org) {
|
||||
@@ -1295,6 +1319,12 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
|
||||
entry.headers = v1Headers
|
||||
}
|
||||
|
||||
const name = String(block.name || '').trim()
|
||||
|
||||
if (kind === 'cloud' && name) {
|
||||
entry.name = name
|
||||
}
|
||||
|
||||
const org = String(block.org || '').trim()
|
||||
|
||||
if (kind === 'cloud' && org) {
|
||||
@@ -1442,7 +1472,7 @@ export function setLastUsedConnection(registry: ConnectionRegistry, id: string):
|
||||
*
|
||||
* Remote-shaped entries are matched by normalized URL across remote/cloud so
|
||||
* changing provenance never duplicates a gateway. Existing identity and
|
||||
* user-chosen label win; a new entry derives both from the host. Switching to
|
||||
* user-chosen label win; a Cloud name upgrades only the default host label. Switching to
|
||||
* local keeps registered remotes available while moving primary/last-used
|
||||
* back to This device.
|
||||
*/
|
||||
@@ -1479,12 +1509,16 @@ export function reconcileAppliedGlobalConnection(
|
||||
|
||||
const kind: ConnectionKind = mode === 'cloud' ? 'cloud' : 'remote'
|
||||
|
||||
const hostLabel = hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway')
|
||||
const name = kind === 'cloud' ? String(block.name ?? existing?.name ?? '').trim() : ''
|
||||
|
||||
const label =
|
||||
existing?.label ||
|
||||
uniqueLabel(
|
||||
hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'),
|
||||
registry.connections.map(connection => connection.label)
|
||||
)
|
||||
existing && (!name || existing.label !== hostLabel)
|
||||
? existing.label
|
||||
: uniqueLabel(
|
||||
name || hostLabel,
|
||||
registry.connections.filter(connection => connection.id !== existing?.id).map(connection => connection.label)
|
||||
)
|
||||
|
||||
const entry = normalizeConnectionInput(
|
||||
{
|
||||
@@ -1495,7 +1529,8 @@ export function reconcileAppliedGlobalConnection(
|
||||
authMode: block.authMode,
|
||||
token: block.token,
|
||||
headers: block.headers,
|
||||
org: block.org
|
||||
org: block.org,
|
||||
name
|
||||
},
|
||||
registry
|
||||
)
|
||||
|
||||
@@ -9304,6 +9304,7 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
||||
token?: object
|
||||
headers?: object
|
||||
org?: string
|
||||
name?: string
|
||||
savedSsh?: object
|
||||
} = {
|
||||
mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'
|
||||
@@ -9338,6 +9339,12 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
||||
// Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can
|
||||
// reopen into the same org for a per-profile cloud connection.
|
||||
if (cleaned.mode === 'cloud') {
|
||||
const cloudName = String(entry.name || '').trim()
|
||||
|
||||
if (cloudName) {
|
||||
cleaned.name = cloudName
|
||||
}
|
||||
|
||||
const org = String(entry.org || '').trim()
|
||||
|
||||
if (org) {
|
||||
@@ -9865,12 +9872,12 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered
|
||||
// under — persisted so Settings can reopen into the same org; omitted from the
|
||||
// block when empty so plain remote connections stay unchanged.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: object) {
|
||||
function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: object, name?: string) {
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
|
||||
const block: { url: string; authMode: string; token: object; headers?: object; org?: string } = {
|
||||
const block: { url: string; authMode: string; token: object; headers?: object; org?: string; name?: string } = {
|
||||
url: normalizeRemoteBaseUrl(remoteUrl),
|
||||
authMode,
|
||||
token
|
||||
@@ -9882,6 +9889,12 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: ob
|
||||
block.headers = remoteHeaders
|
||||
}
|
||||
|
||||
const nameValue = typeof name === 'string' ? name.trim() : ''
|
||||
|
||||
if (nameValue) {
|
||||
block.name = nameValue
|
||||
}
|
||||
|
||||
const orgValue = typeof org === 'string' ? org.trim() : ''
|
||||
|
||||
if (orgValue) {
|
||||
@@ -9919,6 +9932,19 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
|
||||
// inherit the saved org. A plain 'remote' connection never carries an org
|
||||
// (switching cloud→remote drops it), so it stays unset unless mode is cloud.
|
||||
const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : ''
|
||||
|
||||
// A saved name belongs to this exact gateway, not another instance in the same org.
|
||||
const cloudName =
|
||||
mode === 'cloud'
|
||||
? String(
|
||||
input.cloudName ??
|
||||
(existingBlock.url && normalizeRemoteBaseUrl(remoteUrl) === normalizeRemoteBaseUrl(existingBlock.url)
|
||||
? existingBlock.name
|
||||
: '') ??
|
||||
''
|
||||
).trim()
|
||||
: ''
|
||||
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
|
||||
const remoteHeaders =
|
||||
@@ -9962,7 +9988,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
|
||||
if (remoteLike) {
|
||||
profiles[key] = {
|
||||
mode,
|
||||
...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders)
|
||||
...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders, cloudName)
|
||||
}
|
||||
} else {
|
||||
const localEntry = localProfileEntry(rawExistingBlock)
|
||||
@@ -9982,7 +10008,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
|
||||
}
|
||||
|
||||
const nextRemote = remoteLike
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders)
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders, cloudName)
|
||||
: existingMode === 'ssh'
|
||||
? rawExistingBlock
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Collect the component graph before the behavioral test deadline starts.
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
|
||||
const { registry, activeId, selectConnection } = vi.hoisted(() => ({
|
||||
registry: { value: null as any },
|
||||
activeId: { value: 'saved-b' },
|
||||
selectConnection: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@nanostores/react', () => ({ useStore: (store: any) => store.value }))
|
||||
vi.mock('@/store/connections', () => ({
|
||||
$connectionsRegistry: registry,
|
||||
$activeConnectionId: activeId,
|
||||
refreshConnectionsRegistry: vi.fn().mockResolvedValue(null),
|
||||
selectConnection,
|
||||
setConnectionsRegistry: vi.fn()
|
||||
}))
|
||||
vi.mock('./connections-registry', async importOriginal => ({
|
||||
...(await importOriginal<any>()),
|
||||
ConnectionsRegistrySection: () => null
|
||||
}))
|
||||
const getConnectionConfig = vi.fn()
|
||||
const saveConnectionConfig = vi.fn()
|
||||
|
||||
@@ -39,6 +57,69 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('GatewaySettings', () => {
|
||||
it('keeps saved Cloud instances usable without discovery and marks the live source, not the default', async () => {
|
||||
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud', remoteUrl: 'https://a.example' })
|
||||
registry.value = {
|
||||
connections: [
|
||||
{ id: 'saved-a', kind: 'cloud', label: 'Research', url: 'https://a.example', authMode: 'oauth' },
|
||||
{ id: 'saved-b', kind: 'cloud', label: 'Writing', url: 'https://b.example', authMode: 'oauth' }
|
||||
]
|
||||
}
|
||||
const agentSignIn = vi.fn()
|
||||
const applyConnectionConfig = vi.fn()
|
||||
Object.assign(window.hermesDesktop, {
|
||||
applyConnectionConfig,
|
||||
cloud: {
|
||||
status: vi.fn().mockResolvedValue({ signedIn: false }),
|
||||
agentSignIn
|
||||
}
|
||||
})
|
||||
render(<GatewaySettings embedded />)
|
||||
const research = await screen.findByText('Research')
|
||||
const row = research.closest('[data-slot]') ?? research.parentElement!.parentElement!
|
||||
fireEvent.click(within(row as HTMLElement).getByRole('button', { name: 'Use gateway' }))
|
||||
await waitFor(() => expect(selectConnection).toHaveBeenCalledWith('saved-a'))
|
||||
expect(screen.getByText('Active in this window')).toBeTruthy()
|
||||
expect(agentSignIn).not.toHaveBeenCalled()
|
||||
expect(applyConnectionConfig).not.toHaveBeenCalled()
|
||||
registry.value = null
|
||||
})
|
||||
it('authenticates and saves only the chosen discovered instance with its friendly name', async () => {
|
||||
registry.value = null
|
||||
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud' })
|
||||
const agentSignIn = vi.fn().mockResolvedValue({ connected: true })
|
||||
const applyConnectionConfig = vi.fn().mockResolvedValue({ ...localConnection, mode: 'cloud' })
|
||||
Object.assign(window.hermesDesktop, {
|
||||
applyConnectionConfig,
|
||||
cloud: {
|
||||
status: vi.fn().mockResolvedValue({ signedIn: true }),
|
||||
agentSignIn,
|
||||
discover: vi.fn().mockResolvedValue({
|
||||
agents: [
|
||||
{ id: 'new-a', name: 'Research Bot', dashboardUrl: 'https://new-a.example' },
|
||||
{ id: 'new-b', name: 'Writing Bot', dashboardUrl: 'https://new-b.example' }
|
||||
],
|
||||
org: { id: 'org-a' }
|
||||
})
|
||||
}
|
||||
})
|
||||
render(<GatewaySettings embedded />)
|
||||
const buttons = await screen.findAllByRole('button', { name: 'Connect', exact: true })
|
||||
expect(agentSignIn).not.toHaveBeenCalled()
|
||||
expect(applyConnectionConfig).not.toHaveBeenCalled()
|
||||
fireEvent.click(buttons[0])
|
||||
await waitFor(() =>
|
||||
expect(applyConnectionConfig).toHaveBeenCalledWith({
|
||||
mode: 'cloud',
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: 'https://new-a.example',
|
||||
cloudOrg: 'org-a',
|
||||
cloudName: 'Research Bot'
|
||||
})
|
||||
)
|
||||
expect(agentSignIn).toHaveBeenCalledExactlyOnceWith('https://new-a.example')
|
||||
expect(applyConnectionConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('loads the machine-level connection config (no profile scoping)', async () => {
|
||||
render(<GatewaySettings />)
|
||||
expect(await screen.findByText('Local gateway')).toBeTruthy()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -24,6 +25,12 @@ import {
|
||||
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeConnectionId,
|
||||
$connectionsRegistry,
|
||||
refreshConnectionsRegistry,
|
||||
selectConnection
|
||||
} from '@/store/connections'
|
||||
import { notify, notifyError, readableError } from '@/store/notifications'
|
||||
|
||||
import { ConnectionsRegistrySection } from './connections-registry'
|
||||
@@ -169,7 +176,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
const signingSeq = useRef(0)
|
||||
const cloudConnectSeq = useRef(0)
|
||||
const contextSeq = useRef(0)
|
||||
const [connectedCloudUrl, setConnectedCloudUrl] = useState('')
|
||||
const registry = useStore($connectionsRegistry)
|
||||
const activeConnectionId = useStore($activeConnectionId)
|
||||
const savedCloudConnections = registry?.connections.filter(connection => connection.kind === 'cloud') ?? []
|
||||
|
||||
useEffect(() => {
|
||||
void refreshConnectionsRegistry().catch(err => notifyError(err, g.failedLoad))
|
||||
}, [g.failedLoad])
|
||||
|
||||
// Opt-in OS-keychain encryption for stored gateway secrets. Read lazily via
|
||||
// IPC (never touches the keychain); flipping it re-encodes stored secrets
|
||||
@@ -215,7 +228,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
const normalized = normalizeGatewaySettingsState(config)
|
||||
|
||||
setState(normalized)
|
||||
setConnectedCloudUrl(savedCloudConnectionUrl(normalized))
|
||||
}
|
||||
|
||||
// When set, the plain-text opt-in dialog is open; `apply` remembers whether
|
||||
@@ -294,19 +306,29 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = coerceRemoteUrlScheme(state.remoteUrl)
|
||||
|
||||
// The dashboardUrl of the currently-connected cloud instance (the saved
|
||||
// cloud connection's remoteUrl), normalized for comparison against each
|
||||
// discovered agent's dashboardUrl so we can highlight the active one and hide
|
||||
// its Connect button. Empty unless the saved connection is a cloud one.
|
||||
// The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl
|
||||
// (which lowercases the host through URL.toString()), but a discovered agent's
|
||||
// dashboardUrl arrives raw from NAS — so normalize both sides the same way
|
||||
// (trim, drop trailing slash, lowercase) or a host-casing difference would
|
||||
// silently break the connected-highlight.
|
||||
const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase()
|
||||
const savedAgent = (agent: DesktopCloudAgent) =>
|
||||
registry?.connections.find(
|
||||
connection =>
|
||||
(connection.kind === 'cloud' || connection.kind === 'remote') &&
|
||||
connection.url &&
|
||||
agent.dashboardUrl &&
|
||||
savedCloudConnectionUrl({ mode: 'cloud', remoteUrl: connection.url }) ===
|
||||
savedCloudConnectionUrl({ mode: 'cloud', remoteUrl: agent.dashboardUrl })
|
||||
)
|
||||
|
||||
const isConnectedAgent = (agent: DesktopCloudAgent) =>
|
||||
Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl)
|
||||
const isConnectedAgent = (agent: DesktopCloudAgent) => savedAgent(agent)?.id === activeConnectionId
|
||||
|
||||
const activateSavedCloud = async (id: string) => {
|
||||
setCloudConnectingId(id)
|
||||
|
||||
try {
|
||||
await selectConnection(id)
|
||||
} catch (err) {
|
||||
notifyError(err, g.cloudConnectFailed)
|
||||
} finally {
|
||||
setCloudConnectingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
@@ -887,6 +909,16 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
setCloudConnectingId(agent.id)
|
||||
|
||||
try {
|
||||
// Saved sources keep their identity, credentials and default gateway.
|
||||
// The activation path reuses healthy sockets and validates auth on a new dial.
|
||||
const saved = savedAgent(agent)
|
||||
|
||||
if (saved) {
|
||||
await selectConnection(saved.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
|
||||
|
||||
if (seq !== contextSeq.current) {
|
||||
@@ -911,7 +943,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
mode: 'cloud',
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: agent.dashboardUrl,
|
||||
cloudOrg: cloudOrgRef.current ?? undefined
|
||||
cloudOrg: cloudOrgRef.current ?? undefined,
|
||||
cloudName: agent.name
|
||||
})
|
||||
|
||||
if (seq !== contextSeq.current) {
|
||||
@@ -919,6 +952,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
}
|
||||
|
||||
acceptSavedConfig(next)
|
||||
await refreshConnectionsRegistry()
|
||||
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
|
||||
} catch (err) {
|
||||
if (seq !== contextSeq.current) {
|
||||
@@ -1147,6 +1181,40 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
connection. Replaces the URL/token form while in cloud mode. */}
|
||||
{state.mode === 'cloud' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
{savedCloudConnections.length > 0 ? (
|
||||
<div className="mb-4 grid gap-1">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudSavedTitle}
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{g.cloudSavedDesc}</p>
|
||||
{savedCloudConnections.map(connection => (
|
||||
<div data-slot="saved-cloud-gateway" key={connection.id}>
|
||||
<ListRow
|
||||
action={
|
||||
activeConnectionId === connection.id ? (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{g.cloudActive}
|
||||
</Pill>
|
||||
) : (
|
||||
<Button
|
||||
disabled={cloudConnectingId !== null}
|
||||
onClick={() => void activateSavedCloud(connection.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{cloudConnectingId === connection.id ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.cloudUseSaved}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={connection.url}
|
||||
title={connection.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ListRow
|
||||
action={
|
||||
cloudSignedIn ? (
|
||||
@@ -1256,7 +1324,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
connected ? (
|
||||
<Pill tone="primary">
|
||||
<Check className="mr-1 inline size-3" />
|
||||
{g.cloudConnectedPill}
|
||||
{g.cloudActive}
|
||||
</Pill>
|
||||
) : (
|
||||
<Button
|
||||
@@ -1268,13 +1336,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
{agent.dashboardUrl
|
||||
? cloudConnectingId === agent.id
|
||||
? g.cloudConnecting
|
||||
: g.cloudConnect
|
||||
: savedAgent(agent)
|
||||
? g.cloudUseSaved
|
||||
: g.cloudConnect
|
||||
: g.cloudAgentProvisioning}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
|
||||
title={agent.name}
|
||||
title={savedAgent(agent)?.label || agent.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Vendored
+1
@@ -862,6 +862,7 @@ export interface DesktopConnectionConfigInput {
|
||||
// For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to
|
||||
// persist so Settings can reopen into it. Ignored for remote/local modes.
|
||||
cloudOrg?: string
|
||||
cloudName?: string
|
||||
sshHost?: string
|
||||
sshUser?: string
|
||||
sshPort?: number | null
|
||||
|
||||
@@ -922,6 +922,11 @@ export const en: Translations = {
|
||||
},
|
||||
cloudRefresh: 'Refresh',
|
||||
cloudConnect: 'Connect',
|
||||
cloudSavedTitle: 'Saved Cloud gateways',
|
||||
cloudSavedDesc:
|
||||
'Use a saved gateway without changing your default. Sign in below to add instances. Manage names and sign-in in the saved connections list.',
|
||||
cloudUseSaved: 'Use gateway',
|
||||
cloudActive: 'Active in this window',
|
||||
cloudConnecting: 'Connecting…',
|
||||
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
|
||||
cloudConnectFailed: 'Could not connect to that agent',
|
||||
|
||||
@@ -1135,6 +1135,11 @@ export const ru = defineLocale({
|
||||
},
|
||||
cloudRefresh: 'Обновить',
|
||||
cloudConnect: 'Подключиться',
|
||||
cloudSavedTitle: 'Сохранённые облачные шлюзы',
|
||||
cloudSavedDesc:
|
||||
'Используйте сохранённый шлюз без изменения шлюза по умолчанию. Войдите ниже, чтобы добавить экземпляры. Имена и вход — в списке сохранённых подключений.',
|
||||
cloudUseSaved: 'Использовать шлюз',
|
||||
cloudActive: 'Активен в этом окне',
|
||||
cloudConnecting: 'Подключение…',
|
||||
cloudDiscoverFailed: 'Не удалось загрузить агентов Hermes Cloud',
|
||||
cloudConnectFailed: 'Не удалось подключиться к этому агенту',
|
||||
|
||||
@@ -789,6 +789,10 @@ export interface Translations {
|
||||
cloudNoAgents: { before: string; linkText: string; after: string }
|
||||
cloudRefresh: string
|
||||
cloudConnect: string
|
||||
cloudSavedTitle: string
|
||||
cloudSavedDesc: string
|
||||
cloudUseSaved: string
|
||||
cloudActive: string
|
||||
cloudConnecting: string
|
||||
cloudDiscoverFailed: string
|
||||
cloudConnectFailed: string
|
||||
|
||||
@@ -1117,6 +1117,10 @@ export const zh: Translations = {
|
||||
},
|
||||
cloudRefresh: '刷新',
|
||||
cloudConnect: '连接',
|
||||
cloudSavedTitle: '已保存的云网关',
|
||||
cloudSavedDesc: '使用已保存的网关,不更改默认网关。在下方登录以添加实例。在已保存的连接列表中管理名称和登录。',
|
||||
cloudUseSaved: '使用网关',
|
||||
cloudActive: '当前窗口正在使用',
|
||||
cloudConnecting: '正在连接…',
|
||||
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
|
||||
cloudConnectFailed: '无法连接到该智能体',
|
||||
|
||||
Reference in New Issue
Block a user