feat(desktop): persist the selected Hermes Cloud org + instance; restore on reopen

Settings → Gateway remembered 'cloud' mode but not WHICH org/instance, so
reopening dropped multi-org users back to the org picker, hiding the connected
agent (reported live).

- Persist a cloudOrg on the saved cloud connection (rides the remote block:
  coerce reads input.cloudOrg / inherits saved; buildRemoteBlock + profile
  sanitizer carry it; sanitize echoes it back as config.cloudOrg). Only for
  mode:'cloud'; plain remote is unchanged. The instance was already persisted as
  remoteUrl (the dashboardUrl).
- discoverCloudAgents now returns the org NAS echoes in the response
  (trimCloudOrg), and the renderer records cloudOrg AUTHORITATIVELY from
  result.org — so it's set even on single-membership auto-resolve where no
  picker ran (the exact case that left the org unpersisted). Requires NAS #550
  (echo resolved org in /api/agents); before that deploys, falls back to the
  requested org.
- On open, the cloud-status effect seeds cloudOrg from the persisted
  config.cloudOrg and discovers scoped to it, so Settings reopens straight into
  that org's agent list instead of the picker.
- connectCloudAgent passes cloudOrg when saving so the choice sticks.
- The connected instance is highlighted (primary tint + ring) and shows a
  'Connected' pill instead of a Connect button (compares saved remoteUrl to each
  agent's dashboardUrl, normalized).

tsc + eslint clean; 57 node --test + 16 vitest pass. Connected-pill verified live;
org-restore pending NAS #550 deploy for the authoritative echo.

cloud-auto-discovery Phase 3/4 follow-up.
This commit is contained in:
Ben 2026-07-01 12:46:01 +10:00
parent d8c00b8ce2
commit 7e1e9d62c4
7 changed files with 138 additions and 32 deletions

View file

@ -4815,7 +4815,20 @@ async function discoverCloudAgents(org) {
throw error
}
return { agents: trimCloudAgents(body) }
return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) }
}
// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed
// shape the renderer persists, or null when absent/malformed.
function trimCloudOrg(org) {
if (!org || typeof org !== 'object' || typeof org.id !== 'string') return null
return {
id: org.id,
slug: typeof org.slug === 'string' ? org.slug : null,
name: typeof org.name === 'string' ? org.name : org.id,
isPersonal: Boolean(org.isPersonal),
role: typeof org.role === 'string' ? org.role : 'MEMBER'
}
}
// Extract the org list from a 409 org_selection_required error body. The error
@ -4933,6 +4946,14 @@ function sanitizeConnectionProfiles(raw) {
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
// 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 org = String(entry.org || '').trim()
if (org) {
cleaned.org = org
}
}
out[name] = cleaned
}
@ -5064,6 +5085,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
// The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for
// remote/local. Lets Settings → Gateway reopen into the same org.
cloudOrg: mode === 'cloud' ? String(block.org || '') : '',
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
// The env override only forces the global/primary connection; a per-profile
@ -5075,11 +5099,19 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
// `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) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const block = { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
block.org = orgValue
}
return block
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
@ -5096,6 +5128,11 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
// Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise
// 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() : ''
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const nextToken = incomingToken
? persistToken
@ -5109,7 +5146,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
// default. The mode tag (remote vs cloud) is preserved on the entry.
const profiles = { ...(existing.profiles || {}) }
if (remoteLike) {
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }
} else {
delete profiles[key]
}
@ -5121,7 +5158,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
}
const nextRemote = remoteLike
? buildRemoteBlock(remoteUrl, authMode, nextToken)
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.

View file

@ -33,6 +33,7 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
cloudOrg: string
}
const EMPTY_STATE: GatewaySettingsState = {
@ -42,7 +43,8 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
remoteUrl: '',
cloudOrg: ''
}
function ModeCard({
@ -186,6 +188,21 @@ export function GatewaySettings() {
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
// 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.
const connectedCloudUrl =
state.mode === 'cloud' ? state.remoteUrl.trim().replace(/\/+$/, '') : ''
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(
connectedCloudUrl &&
agent.dashboardUrl &&
agent.dashboardUrl.trim().replace(/\/+$/, '') === connectedCloudUrl
)
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
@ -433,7 +450,16 @@ export function GatewaySettings() {
// Single org (or org now chosen): we have agents.
setCloudAgents('agents' in result ? result.agents : [])
if (org) {
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
// list was scoped to), falling back to the org we requested. This is what
// gets persisted on connect, so it must be set even on single-membership
// auto-resolve where no picker ran and no `org` arg was passed.
const resolvedOrgRef =
'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
if (resolvedOrgRef) {
setCloudOrg(resolvedOrgRef)
} else if (org) {
setCloudOrg(org)
}
@ -483,7 +509,17 @@ export function GatewaySettings() {
setCloudSignedIn(status.signedIn)
if (status.signedIn) {
void discoverCloud()
// Restore the persisted org (if any) so we reopen straight into that
// org's agent list instead of the picker; discoverCloud(org) also
// records it as the selected org. Empty → normal discovery (single-org
// resolves automatically; multi-org shows the picker).
const savedOrg = state.cloudOrg || ''
if (savedOrg) {
setCloudOrg(savedOrg)
}
void discoverCloud(savedOrg || undefined)
} else {
setCloudAgents([])
setCloudOrgs([])
@ -578,11 +614,13 @@ export function GatewaySettings() {
}
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
// Include the selected org so Settings reopens into the same org + instance.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrg ?? undefined
})
setState(next)
@ -806,27 +844,45 @@ export function GatewaySettings() {
</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}
/>
))}
{cloudAgents.map(agent => {
const connected = isConnectedAgent(agent)
return (
<div
className={cn(
'rounded-md px-2',
connected && 'bg-primary/5 ring-1 ring-primary/25'
)}
key={agent.id}
>
<ListRow
action={
connected ? (
<Pill tone="primary">
<Check className="mr-1 inline size-3" />
{g.cloudConnectedPill}
</Pill>
) : (
<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)}
title={agent.name}
/>
</div>
)
})}
</div>
)}
</div>

View file

@ -14,6 +14,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
cloudOrg: '',
...overrides
}
}

View file

@ -412,6 +412,10 @@ export interface DesktopConnectionConfig {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
// For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the
// connected instance was discovered under, so Settings → Gateway can reopen
// into that org. Empty string for remote/local.
cloudOrg: string
}
export interface DesktopConnectionConfigInput {
@ -422,6 +426,9 @@ export interface DesktopConnectionConfigInput {
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
// 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
}
export interface DesktopConnectionTestResult {
@ -493,9 +500,11 @@ export interface DesktopCloudOrg {
// Discovery result: either the agent list, OR a request to pick an org first
// (multi-org user, no org chosen yet). The renderer shows a picker on the
// latter and re-calls discover(org).
// latter and re-calls discover(org). On the agents branch, `org` echoes the
// authoritatively-resolved org the list was scoped to (from NAS), so the
// desktop persists it without relying on transient picker state.
export type DesktopCloudDiscoverResult =
| { agents: DesktopCloudAgent[]; needsOrgSelection?: false }
| { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false }
| { needsOrgSelection: true; orgs: DesktopCloudOrg[] }
export interface DesktopCloudAgentSignInResult {

View file

@ -558,6 +558,7 @@ export const en: Translations = {
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
cloudConnectedTitle: 'Connected',
cloudConnectedPill: 'Connected',
cloudConnectedTo: name => `Connected to ${name}.`,
cloudAgentProvisioning: 'Provisioning…',
cloudStatusLabel: status => `Status: ${status}`,

View file

@ -465,6 +465,7 @@ export interface Translations {
cloudSignedOutTitle: string
cloudSignedOutMessage: string
cloudConnectedTitle: string
cloudConnectedPill: string
cloudConnectedTo: (name: string) => string
cloudAgentProvisioning: string
cloudStatusLabel: (status: string) => string

View file

@ -747,6 +747,7 @@ export const zh: Translations = {
cloudSignedOutTitle: '已退出 Hermes Cloud',
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
cloudConnectedTitle: '已连接',
cloudConnectedPill: '已连接',
cloudConnectedTo: name => `已连接到 ${name}。`,
cloudAgentProvisioning: '正在配置…',
cloudStatusLabel: status => `状态:${status}`,