feat(desktop): add startup and onboarding flow

Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours.
This commit is contained in:
Brooklyn Nicholson
2026-05-07 22:33:44 -04:00
parent fc9d18b03f
commit 89d5ee4b10
17 changed files with 1056 additions and 145 deletions
@@ -3,6 +3,8 @@ import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { DesktopBootOverlay } from '@/components/desktop-boot-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
@@ -395,6 +397,17 @@ export function DesktopController() {
const overlays = (
<>
<DesktopBootOverlay />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onOpenSettings={openSettings}
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
{settingsOpen && (
@@ -1,6 +1,13 @@
import { useEffect, useRef } from 'react'
import { HermesGateway } from '@/hermes'
import {
$desktopBoot,
applyDesktopBootProgress,
completeDesktopBoot,
failDesktopBoot,
setDesktopBootStep
} from '@/store/boot'
import { setGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
@@ -44,11 +51,24 @@ export function useGatewayBoot({
const desktop = window.hermesDesktop
if (!desktop) {
failDesktopBoot('Desktop IPC bridge is unavailable.')
setSessionsLoading(false)
return () => void (cancelled = true)
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
.catch(() => undefined)
setDesktopBootStep({
phase: 'renderer.boot',
message: 'Starting desktop connection',
progress: 6
})
const gateway = new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
@@ -57,6 +77,10 @@ export function useGatewayBoot({
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const offExit = desktop.onBackendExit(() => {
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot('Hermes background process exited during startup.')
}
notify({
kind: 'error',
title: 'Backend stopped',
@@ -73,6 +97,11 @@ export function useGatewayBoot({
return
}
setDesktopBootStep({
phase: 'renderer.gateway.connect',
message: 'Connecting live desktop gateway',
progress: 95
})
callbacksRef.current.onConnectionReady(conn)
setConnection(conn)
await gateway.connect(conn.wsUrl)
@@ -81,15 +110,28 @@ export function useGatewayBoot({
return
}
setDesktopBootStep({
phase: 'renderer.config',
message: 'Loading Hermes settings',
progress: 97
})
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.sessions',
message: 'Loading recent sessions',
progress: 99
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
failDesktopBoot(message)
notifyError(err, 'Desktop boot failed')
setSessionsLoading(false)
}
@@ -103,6 +145,7 @@ export function useGatewayBoot({
offState()
offEvent()
offExit()
offBootProgress()
gateway.close()
callbacksRef.current.onConnectionReady(null)
callbacksRef.current.onGatewayReady(null)
@@ -0,0 +1,58 @@
import { useStore } from '@nanostores/react'
import { Loader } from '@/components/ui/loader'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
export function DesktopBootOverlay() {
const boot = useStore($desktopBoot)
if (!boot.visible) {
return null
}
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
return (
<div
aria-busy={boot.running}
aria-live={hasError ? 'assertive' : 'polite'}
className="fixed inset-0 z-1400 grid place-items-center bg-background/88 backdrop-blur-sm"
role="status"
>
<div className="w-[min(32rem,calc(100%-2rem))] rounded-xl border border-border/80 bg-card/95 p-5 shadow-xl shadow-black/8">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<Loader
aria-hidden="true"
className={cn('size-7 text-primary/80', hasError && 'text-destructive')}
role="presentation"
strokeScale={0.8}
type="rose-curve"
/>
<h2 className="truncate text-sm font-semibold text-foreground">Preparing Hermes Desktop</h2>
</div>
</div>
<p className="mt-3 min-h-5 text-sm text-foreground">{boot.message}</p>
{hasError ? <p className="mt-1 text-xs text-destructive">{boot.error}</p> : null}
<div className="mt-4 h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
hasError && 'bg-destructive'
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-[0.68rem] text-muted-foreground">
<span className="max-w-[78%] truncate font-mono">{boot.phase}</span>
<span>{progress}%</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getEnvVars, setEnvVar } from '@/hermes'
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2, Settings2, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
interface DesktopOnboardingOverlayProps {
enabled: boolean
onCompleted?: () => void
onOpenSettings?: () => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface SetupStatus {
provider_configured?: boolean
}
interface ProviderOption {
key: string
label: string
helper: string
}
const DISMISS_KEY = 'desktop.onboarding.dismissed_until_reload'
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
{
key: 'OPENROUTER_API_KEY',
label: 'OpenRouter',
helper: 'Works with many hosted models and is a good default for new installs.'
},
{
key: 'ANTHROPIC_API_KEY',
label: 'Anthropic',
helper: 'Use Claude models directly.'
},
{
key: 'OPENAI_API_KEY',
label: 'OpenAI',
helper: 'Use OpenAI models directly.'
},
{
key: 'GEMINI_API_KEY',
label: 'Gemini',
helper: 'Use Google Gemini models.'
},
{
key: 'XAI_API_KEY',
label: 'xAI',
helper: 'Use Grok models.'
},
{
key: 'OPENAI_BASE_URL',
label: 'Local / OpenAI-compatible',
helper: 'Use a local or self-hosted OpenAI-compatible endpoint. API key may not be required.'
}
]
function isDismissedForSession() {
try {
return window.sessionStorage.getItem(DISMISS_KEY) === '1'
} catch {
return false
}
}
function dismissForSession() {
try {
window.sessionStorage.setItem(DISMISS_KEY, '1')
} catch {
// Ignore storage failures; in-memory state still dismisses the overlay.
}
}
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
return info?.description ? `${option.label} (${option.key})` : option.label
}
export function DesktopOnboardingOverlay({
enabled,
onCompleted,
onOpenSettings,
requestGateway
}: DesktopOnboardingOverlayProps) {
const [checking, setChecking] = useState(false)
const [dismissed, setDismissed] = useState(isDismissedForSession)
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [error, setError] = useState<string | null>(null)
const [providerConfigured, setProviderConfigured] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key)
const [value, setValue] = useState('')
useEffect(() => {
if (!enabled || dismissed) {
return
}
let cancelled = false
async function checkSetup() {
setChecking(true)
setError(null)
try {
const [status, vars] = await Promise.all([requestGateway<SetupStatus>('setup.status'), getEnvVars()])
if (cancelled) {
return
}
setProviderConfigured(Boolean(status.provider_configured))
setEnvVars(vars)
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
if (firstAvailable) {
setSelectedKey(current => (vars[current] ? current : firstAvailable.key))
}
} catch (err) {
if (!cancelled) {
setProviderConfigured(false)
setError(err instanceof Error ? err.message : String(err))
}
} finally {
if (!cancelled) {
setChecking(false)
}
}
}
void checkSetup()
return () => void (cancelled = true)
}, [dismissed, enabled, requestGateway])
const providerOptions = useMemo(
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
[envVars]
)
const selectedInfo = envVars?.[selectedKey]
const selectedOption = providerOptions.find(option => option.key === selectedKey) ?? PREFERRED_PROVIDER_KEYS[0]
const canSave = selectedKey === 'OPENAI_BASE_URL' ? value.trim().length > 0 : value.trim().length > 8
async function handleSave() {
if (!canSave || saving) {
return
}
setSaving(true)
setError(null)
try {
await setEnvVar(selectedKey, value.trim())
await requestGateway('reload.env').catch(() => undefined)
const status = await requestGateway<SetupStatus>('setup.status')
if (!status.provider_configured) {
setError('Credential was saved, but Hermes still does not see a configured provider.')
return
}
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
setProviderConfigured(true)
setValue('')
onCompleted?.()
} catch (err) {
notifyError(err, `Failed to save ${selectedKey}`)
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}
function handleDismiss() {
dismissForSession()
setDismissed(true)
}
function handleOpenSettings() {
handleDismiss()
onOpenSettings?.()
}
if (!enabled || dismissed || providerConfigured) {
return null
}
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<div className="border-b border-border bg-muted/30 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<KeyRound className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Set up Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
Add one inference provider before starting your first chat. This writes to the current Hermes
profile's `.env` file and takes effect immediately.
</p>
</div>
</div>
<Button onClick={handleDismiss} size="icon-sm" title="Configure later" variant="ghost">
<X className="size-4" />
</Button>
</div>
</div>
<div className="grid gap-5 p-6">
{checking ? (
<div className="flex items-center gap-2 rounded-2xl bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking provider setup...
</div>
) : null}
<div className="grid gap-2">
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Provider</label>
<div className="grid gap-2 sm:grid-cols-2">
{providerOptions.map(option => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
selectedKey === option.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={option.key}
onClick={() => {
setSelectedKey(option.key)
setValue('')
}}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{optionLabel(option, envVars?.[option.key])}</span>
{selectedKey === option.key ? <Check className="size-4 text-primary" /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{option.helper}</p>
</button>
))}
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{selectedKey}
</label>
{selectedInfo?.url ? (
<Button asChild size="xs" variant="ghost">
<a href={selectedInfo.url} rel="noreferrer" target="_blank">
Docs
<ExternalLink className="size-3" />
</a>
</Button>
) : null}
</div>
<Input
autoComplete="off"
autoFocus
className="font-mono"
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
void handleSave()
}
}}
placeholder={selectedKey === 'OPENAI_BASE_URL' ? 'http://127.0.0.1:8000/v1' : 'Paste API key'}
type={selectedInfo?.is_password === false || selectedKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
value={value}
/>
<p className="text-xs leading-5 text-muted-foreground">{selectedOption.helper}</p>
</div>
{error ? (
<div className="flex gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
<Button onClick={handleOpenSettings} variant="outline">
<Settings2 className="size-4" />
Open full settings
</Button>
<div className="flex gap-2">
<Button onClick={handleDismiss} variant="ghost">
Configure later
</Button>
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Saving' : 'Save and continue'}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
+12
View File
@@ -4,6 +4,7 @@ declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getBootProgress: () => Promise<DesktopBootProgress>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@@ -25,6 +26,7 @@ declare global {
onClosePreviewRequested?: (callback: () => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
}
}
}
@@ -37,6 +39,16 @@ export interface HermesConnection {
windowButtonPosition: { x: number; y: number } | null
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean
message: string
phase: string
progress: number
running: boolean
timestamp: number
}
export interface HermesApiRequest {
path: string
method?: string
+90
View File
@@ -0,0 +1,90 @@
import { atom } from 'nanostores'
import type { DesktopBootProgress } from '@/global'
export interface DesktopBootState extends DesktopBootProgress {
visible: boolean
}
const INITIAL_BOOT_STATE: DesktopBootState = {
error: null,
fakeMode: false,
message: 'Starting Hermes Desktop…',
phase: 'renderer.init',
progress: 2,
running: true,
timestamp: Date.now(),
visible: true
}
export const $desktopBoot = atom<DesktopBootState>(INITIAL_BOOT_STATE)
function clampProgress(value: number) {
if (!Number.isFinite(value)) {
return 0
}
return Math.max(0, Math.min(100, Math.round(value)))
}
export function applyDesktopBootProgress(progress: DesktopBootProgress) {
const current = $desktopBoot.get()
const nextProgress = clampProgress(progress.progress)
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
$desktopBoot.set({
...current,
...progress,
error: progress.error ?? null,
progress: mergedProgress,
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
})
}
export function setDesktopBootStep(step: {
phase: string
message: string
progress: number
running?: boolean
fakeMode?: boolean
error?: string | null
}) {
const current = $desktopBoot.get()
applyDesktopBootProgress({
error: step.error ?? null,
fakeMode: step.fakeMode ?? current.fakeMode,
message: step.message,
phase: step.phase,
progress: step.progress,
running: step.running ?? true,
timestamp: Date.now()
})
}
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
error: null,
message,
phase: 'renderer.ready',
progress: 100,
running: false,
timestamp: Date.now(),
visible: false
})
}
export function failDesktopBoot(message: string) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
error: message,
message: `Desktop boot failed: ${message}`,
phase: 'renderer.error',
progress: clampProgress(current.progress),
running: false,
timestamp: Date.now(),
visible: true
})
}