mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
24f6ed53fc
commit
b3bde1fbee
16 changed files with 505 additions and 132 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
3
apps/desktop/src/global.d.ts
vendored
3
apps/desktop/src/global.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '远程网关可访问',
|
||||
|
|
|
|||
65
apps/desktop/src/store/gateway-switch.test.ts
Normal file
65
apps/desktop/src/store/gateway-switch.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
83
apps/desktop/src/store/gateway-switch.ts
Normal file
83
apps/desktop/src/store/gateway-switch.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue