fix(desktop): reliably persist cloud org, unselect cloud on mode switch, keep Change-org button after restore

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.
This commit is contained in:
Ben
2026-07-01 13:28:42 +10:00
parent 34d7d3fe2e
commit 1b54d38e75
2 changed files with 45 additions and 6 deletions

View File

@@ -5124,7 +5124,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)

View File

@@ -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<DesktopCloudOrg[]>([])
const [cloudOrg, setCloudOrg] = useState<null | string>(null)
const [cloudOrg, setCloudOrgState] = useState<null | string>(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 | string>(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}
</div>
<div className="flex items-center gap-2">
{cloudOrgs.length > 1 ? (
// Let a multi-org user switch back to the org picker.
<Button onClick={() => setCloudOrg(null)} size="sm" variant="text">
{cloudOrg ? (
// Let the user switch orgs. Gating on cloudOrgs.length would
// hide this after a restore-open (which discovers straight
// into the saved org and never populates the org list). So
// show it whenever an org is selected: clicking clears the
// org and re-runs discovery with no org arg — a multi-org
// user gets the picker (NAS 409), a single-org user simply
// auto-resolves back to their one org (harmless).
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
{g.cloudOrgChange}
</Button>
) : null}