feat(desktop): Hermes Cloud mode card + agent picker in Gateway settings

Phase 4 of cloud-auto-discovery — the UI on top of the Phase 3 cloud plumbing.

Adds a third 'Hermes Cloud' ModeCard alongside Local/Remote in gateway-settings.
Selecting it reveals the cloud panel instead of the URL/token form:
- signed-out → 'Sign in to Hermes Cloud' (one portal login in the OAuth partition)
- signed-in  → a discovered-agent picker (loading / empty / list states) with a
  Refresh control. Selecting an agent drives the silent per-agent cascade
  (cloud.agentSignIn) then applies a mode:'cloud' connection pointed at its
  dashboardUrl — no second sign-in prompt.
Cloud auto-discovers on entering the mode when a portal session already exists.
Test/Save bottom-row actions are hidden in cloud mode (selection applies the
connection); the remote URL/token form is now gated to remote mode only.

Wires the renderer to the Phase 3 IPC (window.hermesDesktop.cloud.*). i18n
strings added to en + zh (full) and the Translations type; ja/zh-hant inherit via
defineLocale fallback. New 'Cloud' icon (IconCloud) exported from lib/icons.

Validated: tsc clean, eslint clean, vite renderer build succeeds, 52 electron +
16 vitest tests pass.

cloud-auto-discovery Phase 4.
This commit is contained in:
Ben 2026-06-30 13:55:34 +10:00
parent 2704e6e39c
commit 318910ce80
5 changed files with 370 additions and 22 deletions

View file

@ -3,9 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
@ -16,6 +16,8 @@ import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './prim
type Mode = 'local' | 'remote' | 'cloud'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
@ -105,6 +107,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// --- Hermes Cloud (cloud mode) state ---
// One portal session powers discovery + the silent per-agent cascade. These
// track the cloud panel: whether we're signed in, the discovered agent list,
// and which agent is mid-connect.
const [cloudSignedIn, setCloudSignedIn] = useState(false)
const [cloudSigningIn, setCloudSigningIn] = useState(false)
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
@ -379,6 +391,171 @@ export function GatewaySettings() {
}
}
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
const discoverCloud = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudDiscover('loading')
try {
const { agents } = await desktop.cloud.discover()
setCloudAgents(agents)
setCloudDiscover('done')
} catch (err) {
setCloudAgents([])
setCloudDiscover('error')
// A lapsed/absent portal session means we're effectively signed out.
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudDiscoverFailed)
}
}
// On entering cloud mode (or scope change), read the portal session status and
// auto-discover when already signed in, so the picker is populated on open.
useEffect(() => {
if (state.mode !== 'cloud') {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
let cancelled = false
desktop.cloud
.status()
.then(status => {
if (cancelled) {
return
}
setCloudSignedIn(status.signedIn)
if (status.signedIn) {
void discoverCloud()
} else {
setCloudAgents([])
setCloudDiscover('idle')
}
})
.catch(() => {
if (!cancelled) {
setCloudSignedIn(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
}, [state.mode, scope])
const cloudSignIn = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
const result = await desktop.cloud.login()
setCloudSignedIn(result.signedIn)
if (result.signedIn) {
await discoverCloud()
}
} catch (err) {
notifyError(err, g.cloudSignInFailed)
} finally {
setCloudSigningIn(false)
}
}
const cloudSignOut = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
await desktop.cloud.logout()
setCloudSignedIn(false)
setCloudAgents([])
setCloudDiscover('idle')
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
} catch (err) {
notifyError(err, g.signOutFailed)
} finally {
setCloudSigningIn(false)
}
}
// Select a discovered agent: drive the silent per-agent cascade (no second
// prompt — the shared portal session auto-approves), then persist a cloud-mode
// connection pointed at its dashboardUrl and apply it (reconnects the window).
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
if (!agent.dashboardUrl) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudConnectingId(agent.id)
try {
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
if (!result.connected) {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
return
}
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl
})
setState(next)
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
@ -465,7 +642,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@ -474,6 +651,14 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
@ -484,6 +669,92 @@ export function GatewaySettings() {
/>
</div>
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
whose selection drives the silent per-agent cascade + a cloud
connection. Replaces the URL/token form while in cloud mode. */}
{state.mode === 'cloud' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
cloudSignedIn ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.cloudSignedIn}
</Pill>
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{g.cloudSignIn}
</Button>
)
}
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
title={g.cloudSignInTitle}
/>
{cloudSignedIn ? (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudAgentsTitle}
</div>
<Button
disabled={cloudDiscover === 'loading'}
onClick={() => void discoverCloud()}
size="sm"
variant="text"
>
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{g.cloudRefresh}
</Button>
</div>
{cloudDiscover === 'loading' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.cloudLoadingAgents}
</div>
) : cloudAgents.length === 0 ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.cloudNoAgents}
</div>
) : (
<div className="grid gap-1">
{cloudAgents.map(agent => (
<ListRow
action={
<Button
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
onClick={() => void connectCloudAgent(agent)}
size="sm"
>
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
{agent.dashboardUrl
? cloudConnectingId === agent.id
? g.cloudConnecting
: g.cloudConnect
: g.cloudAgentProvisioning}
</Button>
}
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
key={agent.id}
title={agent.name}
/>
))}
</div>
)}
</div>
) : null}
</div>
) : null}
{state.mode === 'remote' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
@ -568,28 +839,36 @@ export function GatewaySettings() {
/>
) : null}
</div>
) : null}
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
above (which applies a cloud connection on select), so its only
bottom-row action would be redundant hidden in cloud mode. */}
{state.mode !== 'cloud' ? (
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'remote' ? (
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : null}
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
) : null}
<div className="mt-6 grid gap-1">
<ListRow

View file

@ -530,6 +530,29 @@ export const en: Translations = {
remoteTitle: 'Remote gateway',
remoteDesc:
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
cloudTitle: 'Hermes Cloud',
cloudDesc:
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: 'Sign in to Hermes Cloud',
cloudSignedIn: 'Signed in to Hermes Cloud',
cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.',
cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.',
cloudAgentsTitle: 'Your agents',
cloudLoadingAgents: 'Loading your agents…',
cloudNoAgents: 'No agents found on this account. Create one in the Nous portal, then refresh.',
cloudRefresh: 'Refresh',
cloudConnect: 'Connect',
cloudConnecting: 'Connecting…',
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
cloudConnectFailed: 'Could not connect to that agent',
cloudSignInFailed: 'Hermes Cloud sign-in failed',
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
cloudConnectedTitle: 'Connected',
cloudConnectedTo: name => `Connected to ${name}.`,
cloudAgentProvisioning: 'Provisioning…',
cloudStatusLabel: status => `Status: ${status}`,
remoteUrlTitle: 'Remote URL',
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
probing: 'Checking how this gateway authenticates…',

View file

@ -442,6 +442,28 @@ export interface Translations {
localDesc: string
remoteTitle: string
remoteDesc: string
cloudTitle: string
cloudDesc: string
cloudSignInTitle: string
cloudSignIn: string
cloudSignedIn: string
cloudNeedsSignIn: string
cloudSignedInDesc: string
cloudAgentsTitle: string
cloudLoadingAgents: string
cloudNoAgents: string
cloudRefresh: string
cloudConnect: string
cloudConnecting: string
cloudDiscoverFailed: string
cloudConnectFailed: string
cloudSignInFailed: string
cloudSignedOutTitle: string
cloudSignedOutMessage: string
cloudConnectedTitle: string
cloudConnectedTo: (name: string) => string
cloudAgentProvisioning: string
cloudStatusLabel: (status: string) => string
remoteUrlTitle: string
remoteUrlDesc: string
probing: string

View file

@ -720,6 +720,28 @@ export const zh: Translations = {
remoteTitle: '远程网关',
remoteDesc:
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
cloudTitle: 'Hermes Cloud',
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: '登录 Hermes Cloud',
cloudSignedIn: '已登录 Hermes Cloud',
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
cloudAgentsTitle: '你的智能体',
cloudLoadingAgents: '正在加载你的智能体…',
cloudNoAgents: '此账户下未找到智能体。请在 Nous 门户中创建一个,然后刷新。',
cloudRefresh: '刷新',
cloudConnect: '连接',
cloudConnecting: '正在连接…',
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
cloudConnectFailed: '无法连接到该智能体',
cloudSignInFailed: 'Hermes Cloud 登录失败',
cloudSignedOutTitle: '已退出 Hermes Cloud',
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
cloudConnectedTitle: '已连接',
cloudConnectedTo: name => `已连接到 ${name}`,
cloudAgentProvisioning: '正在配置…',
cloudStatusLabel: status => `状态:${status}`,
remoteUrlTitle: '远程 URL',
remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀例如 /hermes。',
probing: '正在检查此网关的认证方式…',

View file

@ -26,6 +26,7 @@ import {
IconCircle as CircleIcon,
IconClipboard as Clipboard,
IconClock as Clock,
IconCloud as Cloud,
IconCommand as Command,
IconCopy as Copy,
IconCopy as CopyIcon,
@ -139,6 +140,7 @@ export {
CircleIcon,
Clipboard,
Clock,
Cloud,
Command,
Copy,
CopyIcon,