From 09f96b5f56f5a99bd4633ed35c4d3fb52cf7aef2 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 1 Jul 2026 13:28:42 +1000 Subject: [PATCH] fix(desktop): reliably persist cloud org, unselect cloud on mode switch, keep Change-org button after restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from live testing the org persist/restore flow: 1. Org not persisting (stale closure). discoverCloud() resolves the org asynchronously from the NAS response and setCloudOrg() is a React state update, but connectCloudAgent read the cloudOrg value captured in its render closure — often still null when the user clicked Connect in the same tick, so no org was saved. Mirror the org into a ref (cloudOrgRef) updated synchronously alongside state; connect reads cloudOrgRef.current. 2. Cloud connection lingered after switching away. coerceDesktopConnectionConfig inherits existingBlock.url across mode switches (correct for remote↔local), so switching cloud→local/remote kept the cloud instance URL in the remote block — re-selecting Cloud then looked 'already connected' with no way to re-pick. Added a leavingCloud rule: when the saved block was cloud and the new mode isn't cloud, start from an empty block (drop the cloud url/org/token), cleanly unselecting the cloud gateway. remote↔local toggles still preserve a real remote URL. 3. Change-org button vanished after restore-open. It was gated on cloudOrgs.length > 1, but the restore path discovers straight into the saved org and never populates cloudOrgs. Gate on cloudOrg being set instead, via a new changeCloudOrg() that clears the org + agent list and re-discovers with no org arg (multi-org → NAS 409 picker; single-org → auto-resolve back). Depends on NAS #550 (echo resolved org), merged + live on prod (0dc86d0b). tsc + eslint clean; 57 node --test + 16 vitest pass; all three verified live on Ben's host (org persists + restores, cloud unselects on switch, Change-org shows after reopen). The benign 'Session not found' 404 on backend switch is left as-is (already handled by isSessionGoneError → fresh draft; dev-log noise only). cloud-auto-discovery Phase 3/4 follow-up. --- apps/desktop/electron/main.cjs | 11 ++++- .../src/app/settings/gateway-settings.tsx | 40 ++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 96bcffabf2e..85ae186a122 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5203,7 +5203,16 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. - const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + // Leaving a CLOUD connection unselects it: a cloud block's url/org/token + // describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway, + // so switching to local or remote must NOT inherit them (otherwise the stale + // cloud URL lingers and re-selecting Cloud looks "already connected"). When the + // saved block was cloud and the new mode is not cloud, start from an empty + // block. (remote↔local toggles still preserve a real remote URL as before.) + const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode + const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' + const existingBlock = leavingCloud ? {} : rawExistingBlock 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) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 754987a7732..0ff4244a35b 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -128,7 +128,19 @@ export function GatewaySettings() { // list here and show a picker. `cloudOrg` is the chosen org slug/id (null = // not yet chosen / single-org user). const [cloudOrgs, setCloudOrgs] = useState([]) - const [cloudOrg, setCloudOrg] = useState(null) + const [cloudOrg, setCloudOrgState] = useState(null) + // Mirror the selected org into a ref so connect reads the CURRENT value, not a + // value captured in a stale render closure. discoverCloud() resolves the org + // asynchronously (from the NAS response) and a user can click Connect in the + // same render tick; without the ref, connectCloudAgent could persist a null + // org even though discovery just resolved one. Always set both together. + const cloudOrgRef = useRef(null) + + const setCloudOrg = (value: null | string) => { + cloudOrgRef.current = value + setCloudOrgState(value) + } + // Hermes Cloud is beta-gated: the selector ModeCard only appears when the main // process reports the BETA env flag is enabled. Default hidden until the async // check resolves, so it never flashes in for non-beta users. @@ -512,6 +524,16 @@ export function GatewaySettings() { void discoverCloud(ref) } + // "Change org": clear the selected org and re-discover with no org arg. A + // multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves + // back to their one org. Also clear the agent list so the current org's + // agents don't linger under the picker while discovery re-runs. + const changeCloudOrg = () => { + setCloudOrg(null) + setCloudAgents([]) + void discoverCloud() + } + // 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(() => { @@ -642,12 +664,14 @@ 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. + // Read the REF (not the cloudOrg state) so a just-resolved org from + // discovery in this same render tick is captured, not a stale null. const next = await desktop.applyConnectionConfig({ mode: 'cloud', profile: scope ?? undefined, remoteAuthMode: 'oauth', remoteUrl: agent.dashboardUrl, - cloudOrg: cloudOrg ?? undefined + cloudOrg: cloudOrgRef.current ?? undefined }) setState(next) @@ -837,9 +861,15 @@ export function GatewaySettings() { {g.cloudAgentsTitle}
- {cloudOrgs.length > 1 ? ( - // Let a multi-org user switch back to the org picker. - ) : null}