diff --git a/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts index dec9f4d1ab74..c627b9390590 100644 --- a/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts +++ b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts @@ -24,11 +24,6 @@ function slot(): SurvivorGlobal { return globalThis as unknown as SurvivorGlobal } -/** True only in a dev server with HMR wired up. Production strips this. */ -export function hmrActive(): boolean { - return Boolean(import.meta.hot) -} - /** Park the live gateway so the next module instance can re-adopt it. */ export function stashGatewaySurvivor(survivor: GatewaySurvivor): void { slot()[SURVIVOR_KEY] = survivor diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 09efb2568753..f02ee69f5e6d 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -40,7 +40,7 @@ import { import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states' import type { RpcEvent } from '@/types/hermes' -import { hmrActive, stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' +import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' // After this many consecutive failed reconnects (≈45s with the 1→15s backoff) // raise a recoverable boot error. Otherwise a dropped remote gateway loops the @@ -342,9 +342,10 @@ export function useGatewayBoot({ // HMR adoption: in a dev hot update, the previous effect instance parked its // still-open socket instead of closing it (see the cleanup below). Re-adopt // it so an edit doesn't drop the live agent session. A stale (closed) parked - // socket is discarded and we boot fresh. No-op in production (survivor is - // always null — the stash path is gated on import.meta.hot). - const survivor = takeGatewaySurvivor() + // socket is discarded and we boot fresh. No-op in production: import.meta.hot + // is undefined there, so this folds to `null` and the whole survivor module + // dead-code-eliminates out of the bundle. + const survivor = import.meta.hot ? takeGatewaySurvivor() : null const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor)) if (survivor && !adoptedFromHmr) { @@ -595,7 +596,7 @@ export function useGatewayBoot({ // gateway store (HMR-stable module state), so they survive untouched. // Production: import.meta.hot is undefined, so this branch never runs and // the original destructive teardown below is byte-for-byte preserved. - if (hmrActive() && gateway.connectionState === 'open') { + if (import.meta.hot && gateway.connectionState === 'open') { stashGatewaySurvivor({ gateway, profile: survivor?.profile ?? $activeGatewayProfile.get(), diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 5027273f86eb..61b6f46abcc7 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -60,24 +60,36 @@ interface GatewayRegistryState { const STATE_KEY = Symbol.for('hermes.desktop.gatewayRegistryState') -function gatewayState(): GatewayRegistryState { - const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState } +function createRegistryState(): GatewayRegistryState { + return { + config: null, + primaryGateway: null, + primaryProfile: 'default', + activeKey: 'default', + secondaries: new Map(), + // The active gateway instance, exposed for inline message-stream + // components (inline ClarifyTool, model overlays) that call gateway + // methods without the instance threaded down through props. + $gateway: atom(null) + } +} - if (!store[STATE_KEY]) { - store[STATE_KEY] = { - config: null, - primaryGateway: null, - primaryProfile: 'default', - activeKey: 'default', - secondaries: new Map(), - // The active gateway instance, exposed for inline message-stream - // components (inline ClarifyTool, model overlays) that call gateway - // methods without the instance threaded down through props. - $gateway: atom(null) - } +// Dev only: park the singletons on globalThis so an HMR re-eval of this module +// (self-accepted at the bottom) hands back the SAME live sockets/atoms instead +// of resetting them — that's what keeps the agent session alive across UI edits. +// `import.meta.hot` is undefined in production, so Vite dead-code-eliminates the +// entire globalThis branch and prod uses a plain module-local singleton — no +// globalThis, no Symbol.for. Both realms load the module once, so the container's +// shape and lifetime are identical either way. +function gatewayState(): GatewayRegistryState { + if (import.meta.hot) { + const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState } + store[STATE_KEY] ??= createRegistryState() + + return store[STATE_KEY] } - return store[STATE_KEY] + return createRegistryState() } const g = gatewayState()