diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d47e2403eaaa..922b7b2b8db1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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. diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 0130dab6cc1f..44b363425682 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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() { ) : (
- {cloudAgents.map(agent => ( - void connectCloudAgent(agent)} - size="sm" - > - {cloudConnectingId === agent.id ? : null} - {agent.dashboardUrl - ? cloudConnectingId === agent.id - ? g.cloudConnecting - : g.cloudConnect - : g.cloudAgentProvisioning} - - } - description={g.cloudStatusLabel(agent.dashboardGatewayState)} - key={agent.id} - title={agent.name} - /> - ))} + {cloudAgents.map(agent => { + const connected = isConnectedAgent(agent) + + return ( +
+ + + {g.cloudConnectedPill} + + ) : ( + + ) + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + title={agent.name} + /> +
+ ) + })}
)} diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index cb55cd6772c6..bf6f4f7ab91f 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -14,6 +14,7 @@ function config(overrides: Partial = {}): DesktopConnec remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: 'https://box:9119', + cloudOrg: '', ...overrides } } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index cf5089392f56..751103c3a5ad 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d0bbdb6e9fde..65de41a688e1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2d3e3d4bc121..39f990dcfca2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 9af001ba7369..295a6aaf9c1c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -747,6 +747,7 @@ export const zh: Translations = { cloudSignedOutTitle: '已退出 Hermes Cloud', cloudSignedOutMessage: '已清除 Hermes Cloud 会话。', cloudConnectedTitle: '已连接', + cloudConnectedPill: '已连接', cloudConnectedTo: name => `已连接到 ${name}。`, cloudAgentProvisioning: '正在配置…', cloudStatusLabel: status => `状态:${status}`,