diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 44dd6445b73..ba50d6d7cc1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 } @@ -5558,20 +5567,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 @@ -5584,9 +5579,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)}` : '' @@ -5601,7 +5598,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 @@ -5622,10 +5622,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 @@ -5663,7 +5661,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. @@ -5697,7 +5703,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 }) @@ -6328,26 +6336,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) { @@ -7668,10 +7707,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) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 519df08dcac..8b6a14b0cf7 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index eb893c3675a..2672e95a676 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -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), 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 26a4e2ce7c8..f1b040bec1e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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() diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index dbabfa32da5..2be223b341e 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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 ( @@ -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(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() { ) : null} -
- setState(current => ({ ...current, mode: 'local' }))} - title={g.localTitle} - /> - setState(current => ({ ...current, mode: 'cloud' }))} - title={g.cloudTitle} - /> - setState(current => ({ ...current, mode: 'remote' }))} - title={g.remoteTitle} - /> +
+
+ {g.modeTitle} +
+
+ setState(current => ({ ...current, mode: 'local' }))} + title={g.localTitle} + /> + setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + /> + setState(current => ({ ...current, mode: 'remote' }))} + title={g.remoteTitle} + /> +
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker @@ -864,10 +879,7 @@ export function GatewaySettings() { {g.cloudNoAgents.before} - + {g.cloudNoAgents.linkText} {g.cloudNoAgents.after} @@ -1053,6 +1065,26 @@ export function GatewaySettings() { description={g.diagnosticsDesc} title={g.diagnostics} /> + {import.meta.env.DEV ? ( + { + setPreviewingSwitch(true) + void previewGatewaySwitch().finally(() => setPreviewingSwitch(false)) + }} + size="sm" + variant="textStrong" + > + {previewingSwitch ? : null} + Preview soft switch + + } + description="Wipe session lists so sidebar skeletons retrigger — no real backend teardown." + title="Dev · soft switch" + /> + ) : null}
) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index bb17f79c3cd..5aca0efbe7d 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -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) } diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index e5e49315985..40ef1ff607b 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -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( + <> + + + + ) + + $gatewaySwitching.set(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + running: true, + visible: true, + progress: 4, + error: null + }) + setGatewayState('closed') + rerender( + <> + + + + ) + + 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 diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index bff722b9a28..aa206514750 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -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('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. diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 06abd4b7945..10a107727a5 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -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' diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index b3e012d976f..6b596b0ce77 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -24,18 +24,21 @@ function TooltipContent({ return ( - {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). */} + + {children} + ) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f5123d2a4ce..a6f82fa309d 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1a326bedcfb..b4520ec13c6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 20e41ee97e2..031f5c78f6d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e03a18d14ff..350a50311e0 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '远程网关可访问', diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts new file mode 100644 index 00000000000..b50cfb059af --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts new file mode 100644 index 00000000000..44be58186d0 --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.ts @@ -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 { + if ($gatewaySwitching.get()) { + return + } + + $gatewaySwitching.set(true) + wipeSessionListsForGatewaySwitch() + + try { + await new Promise(resolve => { + window.setTimeout(resolve, holdMs) + }) + } finally { + setSessionsLoading(false) + $gatewaySwitching.set(false) + } +}