feat(desktop): soft gateway switch + gateway-settings polish

Switching connection mode (local / cloud agent / remote) no longer
full-window-reloads into the cold-boot CONNECTING screen. The primary
backend is torn down in place (no renderer reload); the shell + Settings
stay up while session lists are wiped so sidebar skeletons retrigger, then
the socket re-dials and config/sessions refresh. Cold-boot CONNECTING
latches off after the first successful boot; the intentional teardown
suppresses the backend-exit toast. Dev affordance: a "Preview soft switch"
button under Gateway diagnostics (Electron has no ?query= entry).

Gateway settings UI brought in line with the rest of Settings:
- Mode cards use the shared selectableCardClass on an equal-height
  auto-rows-fr grid, stacking 1→3 (never an orphaned 2+1); titles wrap
  instead of truncating.
- Remote gateway's auth detail moves into a ? tooltip in the title; drop
  the redundant "connects to the one you choose" from the cloud card.
- textStrong buttons force px-0 so the underline sits flush with the label.
- Tooltip chip uses box-decoration-break: clone so the background hugs each
  wrapped line (bg only on the text), capped at max-w-64.

Fully i18n'd (en + zh; ja/zh-hant inherit via defineLocale).
This commit is contained in:
Brooklyn Nicholson 2026-07-10 02:51:59 -05:00
parent 24f6ed53fc
commit b3bde1fbee
16 changed files with 505 additions and 132 deletions

View file

@ -802,6 +802,9 @@ function registerMediaProtocol() {
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
// True while connection-config:apply soft-rehomes the primary — suppresses the
// backend-exit toast so an intentional kill doesn't look like a crash.
let softRehomeInProgress = false
// Additional per-profile backends, keyed by profile name. The PRIMARY backend
// (the desktop's launch profile) stays managed by hermesProcess +
// connectionPromise + startHermes(); this pool only holds EXTRA profile
@ -4491,6 +4494,12 @@ function getWindowState() {
}
function sendBackendExit(payload) {
// Intentional soft re-home (gateway mode apply) kills the child on purpose —
// don't surface the "backend stopped" error toast / boot-failure path.
if (softRehomeInProgress) {
return
}
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
@ -5552,20 +5561,6 @@ function openPortalLoginWindow() {
})
}
// A "portal session is gone, re-login" error the renderer flips to signed-out
// on (via the `needsCloudLogin` tag). `cause` chains the underlying 401 when
// the session lapsed mid-flight.
function cloudLoginError(message, cause?) {
const err = new Error(message) as any
err.needsCloudLogin = true
if (cause) {
err.cause = cause
}
return err
}
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
// session cookie is attached automatically (no bearer needed — NAS accepts the
@ -5578,9 +5573,11 @@ async function discoverCloudAgents(org?: string) {
const portalBaseUrl = resolvePortalBaseUrl()
if (!(await hasLivePortalSession())) {
throw cloudLoginError(
const err = new Error(
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
)
) as any
err.needsCloudLogin = true
throw err
}
const orgQuery = org ? `?org=${encodeURIComponent(org)}` : ''
@ -5595,7 +5592,10 @@ async function discoverCloudAgents(org?: string) {
// A 401 means the portal session lapsed between the liveness check and the
// call — surface it as a re-login, not a generic failure.
if (error && error.statusCode === 401) {
throw cloudLoginError('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.', error)
const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.') as any
err.needsCloudLogin = true
err.cause = error
throw err
}
// A 409 means we're a multi-org user who hasn't picked an org. The body
@ -5616,10 +5616,8 @@ async function discoverCloudAgents(org?: string) {
return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) }
}
// Project a NAS org row ({ id, slug, name, isPersonal, role }) to the trimmed
// shape the renderer consumes/persists, or null when absent/malformed. Shared
// by the success-body echo (trimCloudOrg) and the 409 org list, so the two can
// never drift.
// 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
@ -5657,7 +5655,15 @@ function parseOrgSelectionError(error) {
return null
}
return parsed.orgs.map(trimCloudOrg).filter(Boolean)
return parsed.orgs
.filter(o => o && typeof o === 'object' && typeof o.id === 'string')
.map(o => ({
id: o.id,
slug: typeof o.slug === 'string' ? o.slug : null,
name: typeof o.name === 'string' ? o.name : o.id,
isPersonal: Boolean(o.isPersonal),
role: typeof o.role === 'string' ? o.role : 'MEMBER'
}))
}
// Project NAS's agent rows to the trimmed DTO the renderer consumes.
@ -5691,7 +5697,9 @@ async function cloudAgentSilentSignIn(dashboardUrl) {
// interactive prompt rather than a silent cascade. Discovery already gates on
// this, but a selection can arrive after the session lapsed.
if (!(await hasLivePortalSession())) {
throw cloudLoginError('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.')
const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any
err.needsCloudLogin = true
throw err
}
await openOauthLoginWindow(baseUrl, { silent: true })
@ -6322,26 +6330,57 @@ function stopBackendChild(child) {
}
}
function resetHermesConnection() {
// Soft gateway-mode apply: tear down the primary without resetting boot UI or
// reloading the renderer. The shell stays up; the renderer wipes session lists
// (so skeletons retrigger) and re-dials. Distinct from hard re-home (profile
// switch / crash recovery), which still resets boot progress + reloads.
function resetHermesConnection({ soft = false } = {}) {
connectionPromise = null
backendStartFailure = null
stopBackendChild(hermesProcess)
hermesProcess = null
resetBootProgressForReconnect()
if (!soft) {
resetBootProgressForReconnect()
}
}
// Re-home the primary backend: reset connection state, then wait for the live
// dashboard process to actually exit (SIGKILL after 5s) so the next
// startHermes() spawns fresh instead of racing the dying one. Shared by the
// connection-config and profile switch flows.
async function teardownPrimaryBackendAndWait() {
async function teardownPrimaryBackendAndWait({ soft = false } = {}) {
// Capture the reference before resetHermesConnection() nulls hermesProcess.
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
await waitForBackendExit(dying)
if (soft) {
softRehomeInProgress = true
}
try {
resetHermesConnection({ soft })
await waitForBackendExit(dying)
} finally {
if (soft) {
softRehomeInProgress = false
}
}
}
function sendConnectionApplied() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:connection:applied')
}
async function waitForBackendExit(child, timeoutMs = 5000) {
@ -7648,10 +7687,11 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
// re-resolves against the new remote/local target.
stopPoolBackend(key)
} else {
// Global connection, or the primary profile's connection: re-home the
// window backend by tearing it down and reloading the renderer.
await teardownPrimaryBackendAndWait()
mainWindow?.reload()
// Global / primary connection: soft re-home. Tear down the window backend
// without resetting boot UI or reloading — the shell stays, the renderer
// wipes session lists (skeletons) and re-dials on hermes:connection:applied.
await teardownPrimaryBackendAndWait({ soft: true })
sendConnectionApplied()
}
return sanitizeDesktopConnectionConfig(config, payload?.profile)

View file

@ -204,6 +204,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
// should wipe session lists + re-dial without a window reload.
onConnectionApplied: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:connection:applied', listener)
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)

View file

@ -97,6 +97,7 @@ function fakeDesktop() {
})),
onBootProgress: vi.fn(() => () => undefined),
onBackendExit: vi.fn(() => () => undefined),
onConnectionApplied: vi.fn(() => () => undefined),
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),

View file

@ -23,6 +23,7 @@ import {
setPrimaryGateway,
touchSecondaryGateways
} from '@/store/gateway'
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
@ -130,7 +131,7 @@ export function useGatewayBoot({
}
const attemptReconnect = async () => {
if (cancelled || reconnecting || gatewayOpen()) {
if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) {
return
}
@ -181,7 +182,7 @@ export function useGatewayBoot({
} finally {
reconnecting = false
if (!cancelled && !gatewayOpen()) {
if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) {
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
escalated = true
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
@ -193,7 +194,7 @@ export function useGatewayBoot({
}
function scheduleReconnect() {
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) {
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) {
return
}
@ -207,7 +208,7 @@ export function useGatewayBoot({
}
const reconnectNow = () => {
if (cancelled || !bootCompleted) {
if (cancelled || !bootCompleted || $gatewaySwitching.get()) {
return
}
@ -221,7 +222,97 @@ export function useGatewayBoot({
}
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
// Adopt the profile the primary (window) backend booted as, so same-profile
// resumes are no-op swaps and reconnects target the right backend.
// Best-effort: a missing preference means "default". Shared by boot + soft
// switch.
async function adoptPrimaryProfile() {
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
}
// Seed the working dir from the backend default on a fresh view (nothing
// open yet). Shared by boot + soft switch.
async function seedDefaultCwd() {
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
}
// Soft gateway-mode apply: main tore down the primary without reloading.
// Wipe session lists so skeletons retrigger, then re-dial in place.
const softSwitch = async () => {
if (cancelled) {
return
}
$gatewaySwitching.set(true)
clearReconnectTimer()
reconnectAttempt = 0
escalated = false
reauthNotified = false
wipeSessionListsForGatewaySwitch()
try {
gateway.close()
closeSecondaryGateways()
const conn = await desktop.getConnection()
if (cancelled) {
return
}
publish(conn)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
}
await adoptPrimaryProfile()
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
completeDesktopBoot()
bootCompleted = true
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
failDesktopBoot(message)
notifyError(err, translateNow('boot.errors.desktopBootFailed'))
setSessionsLoading(false)
}
} finally {
$gatewaySwitching.set(false)
}
}
const offBootProgress = desktop.onBootProgress(payload => {
// Soft switch / post-boot startHermes re-emits progress — ignore so the
// cold-boot CONNECTING overlay stays down. Errors still surface.
if ($gatewaySwitching.get() || bootCompleted) {
if (payload.error) {
applyDesktopBootProgress(payload)
}
return
}
applyDesktopBootProgress(payload)
})
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
@ -258,7 +349,7 @@ export function useGatewayBoot({
if (bootCompleted) {
completeDesktopBoot()
}
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
} else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.
scheduleReconnect()
@ -270,6 +361,7 @@ export function useGatewayBoot({
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch())
const onOnline = () => reconnectNow()
@ -319,6 +411,10 @@ export function useGatewayBoot({
})
const offExit = desktop.onBackendExit(() => {
if ($gatewaySwitching.get()) {
return
}
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
}
@ -357,31 +453,14 @@ export function useGatewayBoot({
return
}
// Record which profile the primary (window) backend booted as, so
// same-profile resumes are no-op swaps and any reconnect targets the
// right backend. Best-effort: a missing preference means "default".
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
await adoptPrimaryProfile()
setDesktopBootStep({
phase: 'renderer.config',
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig()
@ -411,6 +490,7 @@ export function useGatewayBoot({
return () => {
cancelled = true
$gatewaySwitching.set(false)
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
@ -419,6 +499,7 @@ export function useGatewayBoot({
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
offConnectionApplied?.()
offState()
offEvent()
offExit()

View file

@ -3,11 +3,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { previewGatewaySwitch } from '@/store/gateway-switch'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
@ -46,6 +49,7 @@ function ModeCard({
active,
description,
disabled,
hint,
icon: Icon,
onSelect,
title
@ -53,6 +57,7 @@ function ModeCard({
active: boolean
description: string
disabled?: boolean
hint?: string
icon: typeof Monitor
onSelect: () => void
title: string
@ -60,22 +65,29 @@ function ModeCard({
return (
<button
className={cn(
'rounded-xl border p-3 text-left transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)',
disabled && 'cursor-not-allowed opacity-50'
'flex h-full min-h-0 w-full flex-col p-3 text-left disabled:cursor-not-allowed disabled:opacity-50',
selectableCardClass({ active, prominent: true })
)}
disabled={disabled}
onClick={onSelect}
type="button"
>
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
<div className="flex items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 text-[length:var(--conversation-text-font-size)] font-medium">{title}</span>
{hint ? (
<Tip label={hint}>
<span
className="grid size-3.5 shrink-0 cursor-help place-items-center text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={event => event.stopPropagation()}
>
<HelpCircle className="size-3.5" />
</span>
</Tip>
) : null}
{active ? <Check className="ml-auto size-3.5 shrink-0 text-primary" /> : null}
</div>
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
<p className="mt-1.5 flex-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
</button>
@ -105,6 +117,7 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [previewingSwitch, setPreviewingSwitch] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
@ -428,10 +441,6 @@ export function GatewaySettings() {
// --- Hermes Cloud handlers ---
// A rejected cloud IPC call tagged `needsCloudLogin` means the portal session
// lapsed — treat it as signed-out so the panel falls back to the sign-in view.
const cloudLoginLapsed = (err: unknown) => Boolean(err && typeof err === 'object' && 'needsCloudLogin' in err)
// Pull the discovered agent list over the shared portal session. Tolerant of
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
// `org` scopes discovery for multi-org users; when discovery comes back with
@ -479,7 +488,7 @@ export function GatewaySettings() {
setCloudDiscover('error')
// A lapsed/absent portal session means we're effectively signed out.
if (cloudLoginLapsed(err)) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
@ -606,7 +615,7 @@ export function GatewaySettings() {
// Select a discovered agent: drive the silent per-agent cascade (no second
// prompt — the shared portal session auto-approves), then persist a cloud-mode
// connection pointed at its dashboardUrl and apply it (reconnects the window).
// connection pointed at its dashboardUrl and apply it (soft-reconnects in place).
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
if (!agent.dashboardUrl) {
return
@ -633,10 +642,10 @@ export function GatewaySettings() {
return
}
// 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.
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-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,
@ -648,7 +657,7 @@ export function GatewaySettings() {
setState(next)
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (cloudLoginLapsed(err)) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
@ -744,31 +753,37 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.modeTitle}
</div>
<div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
disabled={state.envOverride}
hint={g.remoteAuthHint}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
</div>
</div>
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
@ -864,10 +879,7 @@ export function GatewaySettings() {
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>
{g.cloudNoAgents.before}
<ExternalLink
href="https://portal.nousresearch.com/cloud?setup=instance"
showExternalIcon={false}
>
<ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}>
{g.cloudNoAgents.linkText}
</ExternalLink>
{g.cloudNoAgents.after}
@ -1053,6 +1065,26 @@ export function GatewaySettings() {
description={g.diagnosticsDesc}
title={g.diagnostics}
/>
{import.meta.env.DEV ? (
<ListRow
action={
<Button
disabled={previewingSwitch}
onClick={() => {
setPreviewingSwitch(true)
void previewGatewaySwitch().finally(() => setPreviewingSwitch(false))
}}
size="sm"
variant="textStrong"
>
{previewingSwitch ? <Loader2 className="animate-spin" /> : null}
Preview soft switch
</Button>
}
description="Wipe session lists so sidebar skeletons retrigger — no real backend teardown."
title="Dev · soft switch"
/>
) : null}
</div>
</SettingsContent>
)

View file

@ -124,7 +124,7 @@ export function BootFailureOverlay() {
const switchToLocalGateway = async () => {
setBusy('local')
// applyConnectionConfig reloads the window from the main process.
// Soft apply: tears down the primary and re-dials in place (shell stays).
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined)
setBusy(null)
}

View file

@ -2,6 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $gatewaySwitching } from '@/store/gateway-switch'
import { $desktopOnboarding } from '@/store/onboarding'
import { setGatewayState } from '@/store/session'
@ -23,6 +24,7 @@ import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
function resetStores() {
setGatewayState('idle')
$gatewaySwitching.set(false)
$desktopBoot.set({
error: null,
fakeMode: false,
@ -125,6 +127,35 @@ describe('connecting overlay vs recovery surface', () => {
expect(isRecoveryShown()).toBe(false)
})
it('soft gateway switch keeps the shell — no fullscreen CONNECTING', () => {
setGatewayState('open')
const { rerender } = render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
$gatewaySwitching.set(true)
$desktopBoot.set({
...$desktopBoot.get(),
running: true,
visible: true,
progress: 4,
error: null
})
setGatewayState('closed')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
})
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI

View file

@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $gatewaySwitching } from '@/store/gateway-switch'
import { $gatewayState } from '@/store/session'
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
@ -48,9 +49,17 @@ function scrambledTail(resolvedCount: number): string {
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const gatewaySwitching = useStore($gatewaySwitching)
const [previewing] = useState(forcedPreview)
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
// Once cold boot has completed once, never resurrect the fullscreen overlay
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
const coldBootDoneRef = useRef(false)
if (!boot.running && boot.progress >= 100 && !boot.error) {
coldBootDoneRef.current = true
}
// The full-screen connecting overlay is for initial boot only. After a
// healthy boot, flaky networks / sleep-wake can drop the socket and flip the
@ -58,7 +67,12 @@ export function GatewayConnectingOverlay() {
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting = gatewayState !== 'open' && !boot.error && initialBootActive
const connecting =
!coldBootDoneRef.current &&
!gatewaySwitching &&
gatewayState !== 'open' &&
!boot.error &&
initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.

View file

@ -53,6 +53,14 @@ const buttonVariants = cva(
'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
}
},
compoundVariants: [
// textStrong is a boxless link — size variants still inject px-*; strip
// inline padding so the underline sits flush with the label.
{
variant: 'textStrong',
class: 'px-0 has-[>svg]:px-0'
}
],
defaultVariants: {
variant: 'default',
size: 'default'

View file

@ -24,18 +24,21 @@ function TooltipContent({
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
// Instant, no transition (the Provider's delayDuration=0 + no animate-*
// classes). bg-foreground/text-background auto-inverts per theme: white
// on near-black in light mode, black on white in dark.
className={cn(
'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]',
className
)}
// Transparent, width-capped wrapper. The visible chip is the inner inline
// span so `box-decoration-break: clone` gives a marker-style background
// that hugs EACH wrapped line (bg only on the text, ragged right — no
// rectangular dead space). Instant, no transition (delayDuration=0).
className={cn('z-[200] w-fit max-w-64 select-none', className)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
{/* bg-foreground/text-background auto-inverts per theme. leading-normal
keeps lines readable; py-1 makes the cloned line-boxes overlap just
enough to read as one continuous fill (no gaps between lines). */}
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif]">
{children}
</span>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View file

@ -181,6 +181,9 @@ declare global {
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
// Soft gateway-mode apply: primary backend was torn down without a window
// reload. Wipe session lists (skeletons) and re-dial.
onConnectionApplied?: (callback: () => void) => () => void
onPowerResume?: (callback: () => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>

View file

@ -530,14 +530,15 @@ export const en: Translations = {
envOverrideTitle: 'Environment variables are controlling this desktop session.',
envOverrideDesc:
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
modeTitle: 'Connection mode',
localTitle: 'Local gateway',
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
remoteTitle: 'Remote gateway',
remoteDesc:
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
remoteDesc: 'Connect this desktop shell to a remote Hermes backend.',
remoteAuthHint:
'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
cloudTitle: 'Hermes Cloud',
cloudDesc:
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
cloudDesc: 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste.',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: 'Sign in to Hermes Cloud',
cloudSignedIn: 'Signed in to Hermes Cloud',
@ -550,8 +551,8 @@ export const en: Translations = {
cloudOrgRole: role => `Role: ${role}`,
cloudLoadingAgents: 'Loading your agents…',
cloudNoAgents: {
before: 'No agents found on this account. Create one in ',
linkText: 'Hermes Cloud',
before: 'No agents found on this account. Create one in the ',
linkText: 'Nous portal',
after: ', then refresh.'
},
cloudRefresh: 'Refresh',
@ -600,7 +601,7 @@ export const en: Translations = {
enterUrlFirst: 'Enter a remote URL first.',
restartingTitle: 'Gateway connection restarting',
savedTitle: 'Gateway settings saved',
restartingMessage: 'Hermes Desktop will reconnect using the saved settings.',
restartingMessage: 'Hermes Desktop will reconnect using the saved settings — the shell stays open.',
savedMessage: 'Saved for the next restart.',
connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`,
reachableTitle: 'Remote gateway reachable',

View file

@ -442,10 +442,12 @@ export interface Translations {
profileConnection: (profile: string) => string
envOverrideTitle: string
envOverrideDesc: string
modeTitle: string
localTitle: string
localDesc: string
remoteTitle: string
remoteDesc: string
remoteAuthHint: string
cloudTitle: string
cloudDesc: string
cloudSignInTitle: string

View file

@ -720,13 +720,14 @@ export const zh: Translations = {
profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`,
envOverrideTitle: '环境变量正在控制此桌面会话。',
envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。',
modeTitle: '连接模式',
localTitle: '本地网关',
localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。',
remoteTitle: '远程网关',
remoteDesc:
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。',
remoteAuthHint: '托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
cloudTitle: 'Hermes Cloud',
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: '登录 Hermes Cloud',
cloudSignedIn: '已登录 Hermes Cloud',
@ -739,9 +740,9 @@ export const zh: Translations = {
cloudOrgRole: role => `角色:${role}`,
cloudLoadingAgents: '正在加载你的智能体…',
cloudNoAgents: {
before: '此账户下未找到智能体。前往 ',
linkText: 'Hermes Cloud',
after: ' 创建一个,然后刷新。'
before: '此账户下未找到智能体。请在',
linkText: 'Nous 门户',
after: '创建一个,然后刷新。'
},
cloudRefresh: '刷新',
cloudConnect: '连接',
@ -788,7 +789,7 @@ export const zh: Translations = {
enterUrlFirst: '请先输入远程 URL。',
restartingTitle: '网关连接正在重启',
savedTitle: '网关设置已保存',
restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接。',
restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接(界面保持打开)。',
savedMessage: '已保存,下一次重启生效。',
connectedTo: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` · Hermes ${version}` : ''}`,
reachableTitle: '远程网关可访问',

View file

@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout'
import {
$cronSessions,
$freshDraftReady,
$messagingSessions,
$sessions,
$sessionsLoading,
$sessionsTotal,
setCronSessions,
setFreshDraftReady,
setMessagingSessions,
setSessions,
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
import { $gatewaySwitching, previewGatewaySwitch, wipeSessionListsForGatewaySwitch } from './gateway-switch'
vi.mock('@/lib/query-client', () => ({
queryClient: { invalidateQueries: vi.fn() }
}))
describe('wipeSessionListsForGatewaySwitch', () => {
beforeEach(() => {
$gatewaySwitching.set(false)
setSessions([{ id: 's1', title: 'old', profile: 'default' } as never])
setSessionsTotal(1)
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
setSessionsLoading(false)
setFreshDraftReady(false)
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3)
})
afterEach(() => {
resetSessionsLimit()
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(true)
$gatewaySwitching.set(false)
})
it('clears lists and arms loading so sidebar skeletons retrigger', () => {
wipeSessionListsForGatewaySwitch()
expect($sessions.get()).toEqual([])
expect($sessionsTotal.get()).toBe(0)
expect($cronSessions.get()).toEqual([])
expect($messagingSessions.get()).toEqual([])
expect($sessionsLoading.get()).toBe(true)
expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE)
expect($freshDraftReady.get()).toBe(true)
})
it('previewGatewaySwitch holds skeletons then clears loading', async () => {
await previewGatewaySwitch(20)
expect($sessions.get()).toEqual([])
expect($sessionsLoading.get()).toBe(false)
expect($gatewaySwitching.get()).toBe(false)
})
})

View file

@ -0,0 +1,83 @@
import { atom } from 'nanostores'
import { queryClient } from '@/lib/query-client'
import { resetSessionsLimit } from '@/store/layout'
import {
setActiveSessionId,
setAttentionSessionIds,
setCronSessions,
setFreshDraftReady,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSelectedStoredSessionId,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal,
setWorkingSessionIds
} from '@/store/session'
// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the
// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING
// overlay from resurrecting when startHermes re-emits boot progress.
export const $gatewaySwitching = atom(false)
const PREVIEW_HOLD_MS = 1400
/**
* Clear gateway-bound session UI so sidebar skeletons retrigger.
*
* Sessions live in nanostores (not React Query) refreshSessions merges into
* the existing list, so without an explicit wipe a soft switch would keep
* painting the previous gateway's rows. RQ caches (settings/config/skills) are
* invalidated separately; the live session list is this path.
*
* Does NOT call requestFreshSession() that navigates to NEW_CHAT and would
* close route overlays (Settings). Clear chat state in place; leave the URL
* alone so the user stays where they were (e.g. mid-Gateway settings).
*/
export function wipeSessionListsForGatewaySwitch(): void {
setSessions([])
setSessionsTotal(0)
setSessionProfileTotals({})
setCronSessions([])
setMessagingSessions([])
setMessagingPlatformTotals({})
setMessagingTruncated(false)
setWorkingSessionIds([])
setAttentionSessionIds([])
setSessionsLoading(true)
resetSessionsLimit()
setActiveSessionId(null)
setSelectedStoredSessionId(null)
setMessages([])
setFreshDraftReady(true)
void queryClient.invalidateQueries()
}
/**
* Dev review beat: wipe skeletons for PREVIEW_HOLD_MS clear loading.
* Does not tear down a real backend. Fired from the Settings button (Electron
* has no easy `?query=` entry).
*/
export async function previewGatewaySwitch(holdMs = PREVIEW_HOLD_MS): Promise<void> {
if ($gatewaySwitching.get()) {
return
}
$gatewaySwitching.set(true)
wipeSessionListsForGatewaySwitch()
try {
await new Promise<void>(resolve => {
window.setTimeout(resolve, holdMs)
})
} finally {
setSessionsLoading(false)
$gatewaySwitching.set(false)
}
}