refactor(desktop): split onboarding overlay into store + view

Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom.
This commit is contained in:
Brooklyn Nicholson 2026-05-07 23:43:51 -04:00
parent 85f30e07a5
commit 37d1c57f8a
2 changed files with 555 additions and 572 deletions

View file

@ -3,33 +3,27 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
cancelOAuthSession,
listOAuthProviders,
pollOAuthSession,
setEnvVar,
startOAuthLogin,
submitOAuthCode
} from '@/hermes'
import { Check, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, completeDesktopOnboarding } from '@/store/onboarding'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
import {
$desktopOnboarding,
cancelOnboardingFlow,
copyDeviceCode,
type OnboardingContext,
type OnboardingFlow,
refreshOnboarding,
saveOnboardingApiKey,
setOnboardingCode,
setOnboardingMode,
startProviderOAuth,
submitOnboardingCode
} from '@/store/onboarding'
import type { OAuthProvider } from '@/types/hermes'
interface DesktopOnboardingOverlayProps {
enabled: boolean
onCompleted?: () => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface SetupStatus {
provider_configured?: boolean
}
interface RuntimeCheck {
error?: string
ok?: boolean
requestGateway: OnboardingContext['requestGateway']
}
interface ApiKeyOption {
@ -86,23 +80,7 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
}
]
interface FlowState {
copyState?: 'copied' | 'idle'
errorMessage?: string
expiresAt?: number
provider?: OAuthProvider
start?: OAuthStartResponse
status: 'awaiting_user' | 'error' | 'idle' | 'polling' | 'starting' | 'submitting' | 'success'
submitCode?: string
}
const POLL_INTERVAL_MS = 2000
// Order/display overrides for the OAuth provider catalog. The backend ships
// engineer-flavored names like "Anthropic (Claude API)" and "MiniMax (OAuth)";
// the desktop renames them in-place so the overlay reads like a consumer
// product. Anything not in this map keeps the backend's default name.
const PROVIDER_DISPLAY: Record<string, { description?: string; order: number; title?: string }> = {
const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
nous: { order: 0, title: 'Nous Portal' },
anthropic: { order: 1, title: 'Anthropic Claude' },
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
@ -111,343 +89,59 @@ const PROVIDER_DISPLAY: Record<string, { description?: string; order: number; ti
'qwen-oauth': { order: 5, title: 'Qwen Code' }
}
function displayProviderTitle(provider: OAuthProvider) {
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here.',
device_code: 'Opens a verification page in your browser. Hermes connects automatically.',
external: 'Sign in once in your terminal, then come back to chat.'
}
function providerTitle(provider: OAuthProvider) {
return PROVIDER_DISPLAY[provider.id]?.title ?? provider.name
}
function sortProviders(providers: OAuthProvider[]) {
return [...providers].sort((a, b) => {
const aOrder = PROVIDER_DISPLAY[a.id]?.order ?? 99
const bOrder = PROVIDER_DISPLAY[b.id]?.order ?? 99
const order = (PROVIDER_DISPLAY[a.id]?.order ?? 99) - (PROVIDER_DISPLAY[b.id]?.order ?? 99)
if (aOrder !== bOrder) {
return aOrder - bOrder
}
return a.name.localeCompare(b.name)
return order !== 0 ? order : a.name.localeCompare(b.name)
})
}
export function DesktopOnboardingOverlay({
enabled,
onCompleted,
requestGateway
}: DesktopOnboardingOverlayProps) {
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const onboarding = useStore($desktopOnboarding)
const [providerConfigured, setProviderConfigured] = useState(true)
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[] | null>(null)
const [mode, setMode] = useState<'apikey' | 'oauth'>('oauth')
const [flow, setFlow] = useState<FlowState>({ status: 'idle' })
const [apiKeyOption, setApiKeyOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
const [apiKeyValue, setApiKeyValue] = useState('')
const [apiKeySaving, setApiKeySaving] = useState(false)
const [apiKeyError, setApiKeyError] = useState<string | null>(null)
const cancelledRef = useRef(false)
const pollTimerRef = useRef<number | null>(null)
const shouldShow = enabled || onboarding.requested
const visible = (enabled || onboarding.requested) && !onboarding.configured
const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted })
ctxRef.current = { requestGateway, onCompleted }
const refreshSetupCheck = useMemo(
() => async () => {
try {
const [status, runtime] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => ({}) as SetupStatus),
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck)
])
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
} catch {
return false
}
},
[requestGateway]
const ctx = useMemo<OnboardingContext>(
() => ({
requestGateway: (...args) => ctxRef.current.requestGateway(...args),
onCompleted: () => ctxRef.current.onCompleted?.()
}),
[]
)
useEffect(() => {
if (!shouldShow) {
if (!enabled && !onboarding.requested) {
return
}
cancelledRef.current = false
void refreshOnboarding(ctx)
}, [ctx, enabled, onboarding.requested])
void (async () => {
const ok = await refreshSetupCheck()
if (cancelledRef.current) {
return
}
setProviderConfigured(ok)
if (ok) {
completeDesktopOnboarding()
return
}
try {
const providers = await listOAuthProviders()
if (cancelledRef.current) {
return
}
const ordered = sortProviders(providers.providers)
setOauthProviders(ordered)
setMode(ordered.length > 0 ? 'oauth' : 'apikey')
} catch {
if (!cancelledRef.current) {
setOauthProviders([])
setMode('apikey')
}
}
})()
return () => {
cancelledRef.current = true
}
}, [refreshSetupCheck, shouldShow])
useEffect(
() => () => {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
if (flow.start?.session_id && flow.status !== 'success' && flow.status !== 'idle') {
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
}
},
[flow.start?.session_id, flow.status]
)
function clearPollTimer() {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
async function finalizeSuccess(providerName: string) {
clearPollTimer()
notify({ kind: 'success', title: 'Hermes is ready', message: `${providerName} connected.` })
setFlow({ status: 'success' })
try {
await requestGateway('reload.env').catch(() => undefined)
} catch {
// best effort env reload
}
const ok = await refreshSetupCheck()
if (ok) {
setProviderConfigured(true)
completeDesktopOnboarding()
onCompleted?.()
} else {
setFlow({
status: 'error',
errorMessage: 'Connected, but Hermes still cannot resolve a usable provider. Try another provider.'
})
}
}
async function startProviderFlow(provider: OAuthProvider) {
if (provider.flow === 'external') {
notify({
kind: 'info',
title: `${provider.name} uses an external CLI`,
message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.`
})
return
}
clearPollTimer()
setFlow({ status: 'starting', provider })
try {
const start = await startOAuthLogin(provider.id)
const expiresAt = Date.now() + start.expires_in * 1000
if (start.flow === 'pkce') {
await window.hermesDesktop?.openExternal(start.auth_url).catch(() => undefined)
setFlow({ status: 'awaiting_user', provider, start, expiresAt })
return
}
await window.hermesDesktop?.openExternal(start.verification_url).catch(() => undefined)
setFlow({ status: 'polling', provider, start, expiresAt })
pollTimerRef.current = window.setInterval(() => void pollOAuth(provider, start), POLL_INTERVAL_MS)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, errorMessage: `Could not start sign-in: ${message}` })
}
}
async function pollOAuth(provider: OAuthProvider, start: OAuthStartResponse) {
if (start.flow !== 'device_code') {
return
}
try {
const resp = await pollOAuthSession(provider.id, start.session_id)
if (resp.status === 'approved') {
clearPollTimer()
await finalizeSuccess(provider.name)
} else if (resp.status !== 'pending') {
clearPollTimer()
setFlow({
status: 'error',
provider,
start,
errorMessage: resp.error_message || `Sign-in ${resp.status}.`
})
}
} catch (err) {
clearPollTimer()
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, start, errorMessage: `Polling failed: ${message}` })
}
}
async function submitPkce() {
if (flow.status !== 'awaiting_user' || !flow.provider || flow.start?.flow !== 'pkce') {
return
}
const code = (flow.submitCode || '').trim()
if (!code) {
return
}
const provider = flow.provider
const start = flow.start
setFlow(prev => ({ ...prev, status: 'submitting' }))
try {
const resp = await submitOAuthCode(provider.id, start.session_id, code)
if (resp.ok && resp.status === 'approved') {
await finalizeSuccess(provider.name)
} else {
setFlow({ status: 'error', provider, start, errorMessage: resp.message || 'Token exchange failed.' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, start, errorMessage: message })
}
}
function cancelFlow() {
clearPollTimer()
if (flow.start?.session_id) {
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
}
setFlow({ status: 'idle' })
}
async function copyDeviceCode() {
if (flow.start?.flow !== 'device_code') {
return
}
try {
await navigator.clipboard.writeText(flow.start.user_code)
setFlow(prev => ({ ...prev, copyState: 'copied' }))
window.setTimeout(() => setFlow(prev => ({ ...prev, copyState: 'idle' })), 1500)
} catch {
// clipboard write blocked; user can still type the code manually
}
}
async function saveApiKey() {
const value = apiKeyValue.trim()
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
if (!value || value.length < minLen || apiKeySaving) {
return
}
setApiKeySaving(true)
setApiKeyError(null)
try {
await setEnvVar(apiKeyOption.envKey, value)
await requestGateway('reload.env').catch(() => undefined)
const ok = await refreshSetupCheck()
if (ok) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${apiKeyOption.name} connected.` })
setProviderConfigured(true)
completeDesktopOnboarding()
setApiKeyValue('')
onCompleted?.()
} else {
setApiKeyError(`Saved, but Hermes still cannot reach ${apiKeyOption.name}. Double-check the value.`)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
notifyError(err, `Could not save ${apiKeyOption.name}`)
setApiKeyError(message)
} finally {
setApiKeySaving(false)
}
}
if (!shouldShow || providerConfigured) {
if (!visible) {
return null
}
const oauthList = oauthProviders ?? []
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 gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
Connect a model provider to start chatting. Most options take one click.
</p>
</div>
</div>
</div>
<Header />
<div className="grid gap-5 p-6">
{flow.status === 'idle' || flow.status === 'success' ? (
<ProviderPicker
apiKeyError={apiKeyError}
apiKeyOption={apiKeyOption}
apiKeySaving={apiKeySaving}
apiKeyValue={apiKeyValue}
loading={oauthProviders === null}
mode={mode}
oauthProviders={oauthList}
onApiKeySave={() => void saveApiKey()}
onApiKeySelect={setApiKeyOption}
onApiKeyValueChange={setApiKeyValue}
onModeChange={setMode}
onProviderSelect={provider => void startProviderFlow(provider)}
/>
{onboarding.flow.status === 'idle' || onboarding.flow.status === 'success' ? (
<Picker ctx={ctx} />
) : (
<FlowPanel
flow={flow}
onCancel={cancelFlow}
onCopyCode={() => void copyDeviceCode()}
onSubmitCode={() => void submitPkce()}
onSubmitCodeChange={code => setFlow(prev => ({ ...prev, submitCode: code }))}
/>
<FlowPanel ctx={ctx} flow={onboarding.flow} />
)}
</div>
</div>
@ -455,160 +149,115 @@ export function DesktopOnboardingOverlay({
)
}
interface ProviderPickerProps {
apiKeyError: null | string
apiKeyOption: ApiKeyOption
apiKeySaving: boolean
apiKeyValue: string
loading: boolean
mode: 'apikey' | 'oauth'
oauthProviders: OAuthProvider[]
onApiKeySave: () => void
onApiKeySelect: (option: ApiKeyOption) => void
onApiKeyValueChange: (value: string) => void
onModeChange: (mode: 'apikey' | 'oauth') => void
onProviderSelect: (provider: OAuthProvider) => void
function Header() {
return (
<div className="border-b border-border bg-muted/30 px-6 py-5">
<div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
Connect a model provider to start chatting. Most options take one click.
</p>
</div>
</div>
</div>
)
}
function ProviderPicker({
apiKeyError,
apiKeyOption,
apiKeySaving,
apiKeyValue,
loading,
mode,
oauthProviders,
onApiKeySave,
onApiKeySelect,
onApiKeyValueChange,
onModeChange,
onProviderSelect
}: ProviderPickerProps) {
const hasOauth = oauthProviders.length > 0
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
const canSave = apiKeyValue.trim().length >= minLen
function Picker({ ctx }: { ctx: OnboardingContext }) {
const { mode, providers } = useStore($desktopOnboarding)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
return (
<>
{hasOauth && (
<div
aria-label="Connection method"
className="grid w-full max-w-xs grid-cols-2 gap-1 rounded-full border border-border bg-muted/40 p-1 text-xs font-medium"
role="tablist"
>
<button
aria-selected={mode === 'oauth'}
className={cn(
'rounded-full px-3 py-1.5 text-center transition',
mode === 'oauth' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('oauth')}
role="tab"
type="button"
>
Sign in
</button>
<button
aria-selected={mode === 'apikey'}
className={cn(
'rounded-full px-3 py-1.5 text-center transition',
mode === 'apikey' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('apikey')}
role="tab"
type="button"
>
API key
</button>
</div>
<ModeTabs
mode={mode}
onChange={setOnboardingMode}
tabs={[
{ id: 'oauth', label: 'Sign in' },
{ id: 'apikey', label: 'API key' }
]}
/>
)}
{mode === 'oauth' && hasOauth ? (
<div className="grid gap-2">
{loading ? (
<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" />
Looking up providers...
</div>
{providers === null ? (
<Status icon={<Loader2 className="size-4 animate-spin" />}>Looking up providers...</Status>
) : (
oauthProviders.map(provider => (
<ProviderRow key={provider.id} onSelect={onProviderSelect} provider={provider} />
ordered.map(provider => (
<ProviderRow key={provider.id} onSelect={p => void startProviderOAuth(p, ctx)} provider={provider} />
))
)}
</div>
) : (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(option => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
apiKeyOption.id === option.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={option.id}
onClick={() => onApiKeySelect(option)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{option.name}</span>
{apiKeyOption.id === option.id ? <Check className="size-4 text-primary" /> : null}
</div>
{option.short ? <p className="mt-1 text-xs text-muted-foreground">{option.short}</p> : null}
</button>
))}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{apiKeyOption.description}</p>
{apiKeyOption.docsUrl ? (
<Button asChild size="xs" variant="ghost">
<a href={apiKeyOption.docsUrl} rel="noreferrer" target="_blank">
Get a key
<ExternalLink className="size-3" />
</a>
</Button>
) : null}
</div>
<Input
autoComplete="off"
autoFocus
className="font-mono"
onChange={event => onApiKeyValueChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
onApiKeySave()
}
}}
placeholder={apiKeyOption.placeholder || 'Paste API key'}
type={apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
value={apiKeyValue}
/>
{apiKeyError ? <p className="text-xs text-destructive">{apiKeyError}</p> : null}
</div>
<div className="flex justify-end">
<Button disabled={!canSave || apiKeySaving} onClick={onApiKeySave}>
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{apiKeySaving ? 'Connecting' : 'Connect'}
</Button>
</div>
</div>
<ApiKeyForm ctx={ctx} />
)}
</>
)
}
interface ModeTab<T extends string> {
id: T
label: string
}
interface ModeTabsProps<T extends string> {
mode: T
onChange: (mode: T) => void
tabs: ModeTab<T>[]
}
const TAB_COLS: Record<number, string> = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4'
}
function ModeTabs<T extends string>({ mode, onChange, tabs }: ModeTabsProps<T>) {
return (
<div
aria-label="Connection method"
className={cn(
'grid w-full max-w-xs gap-1 rounded-full border border-border bg-muted/40 p-1 text-xs font-medium',
TAB_COLS[tabs.length] ?? 'grid-cols-2'
)}
role="tablist"
>
{tabs.map(tab => (
<button
aria-selected={tab.id === mode}
className={cn(
'rounded-full px-3 py-1.5 text-center transition',
tab.id === mode ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
key={tab.id}
onClick={() => onChange(tab.id)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</div>
)
}
function ProviderRow({
provider,
onSelect
}: {
provider: OAuthProvider
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const isExternal = provider.flow === 'external'
const title = providerTitle(provider)
const loggedIn = provider.status?.logged_in
const title = displayProviderTitle(provider)
const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight
return (
<button
@ -629,63 +278,117 @@ function ProviderRow({
</span>
) : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{flowSubtitle(provider.flow, title)}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
</div>
{isExternal ? (
<ExternalLink className="size-4 text-muted-foreground transition group-hover:text-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
)}
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
)
}
function flowSubtitle(flow: OAuthProvider['flow'], _title: string) {
if (flow === 'pkce') {
return 'Opens your browser to sign in, then continues here.'
function ApiKeyForm({ ctx }: { ctx: OnboardingContext }) {
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const canSave = value.trim().length >= (isLocal ? 1 : 8)
const submit = async () => {
if (!canSave || saving) {
return
}
setSaving(true)
setError(null)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
if (!result.ok) {
setError(result.message ?? 'Could not save credential.')
} else {
setValue('')
}
setSaving(false)
}
if (flow === 'device_code') {
return 'Opens a verification page in your browser. Hermes connects automatically.'
}
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={o.id}
onClick={() => {
setOption(o)
setValue('')
setError(null)
}}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
</button>
))}
</div>
return 'Sign in once in your terminal, then come back to chat.'
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? (
<Button asChild size="xs" variant="ghost">
<a href={option.docsUrl} rel="noreferrer" target="_blank">
Get a key
<ExternalLink className="size-3" />
</a>
</Button>
) : null}
</div>
<Input
autoComplete="off"
autoFocus
className="font-mono"
onChange={event => setValue(event.target.value)}
onKeyDown={event => event.key === 'Enter' && void submit()}
placeholder={option.placeholder || 'Paste API key'}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<div className="flex justify-end">
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : 'Connect'}
</Button>
</div>
</div>
)
}
interface FlowPanelProps {
flow: FlowState
onCancel: () => void
onCopyCode: () => void
onSubmitCode: () => void
onSubmitCodeChange: (code: string) => void
}
function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChange }: FlowPanelProps) {
const providerTitle = flow.provider ? displayProviderTitle(flow.provider) : ''
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
if (flow.status === 'starting') {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Starting sign-in for {providerTitle}...
</div>
)
return <Status icon={<Loader2 className="size-4 animate-spin" />}>Starting sign-in for {title}...</Status>
}
if (flow.status === 'submitting') {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Verifying your code with {providerTitle}...
</div>
)
return <Status icon={<Loader2 className="size-4 animate-spin" />}>Verifying your code with {title}...</Status>
}
if (flow.status === 'success') {
return (
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4" />
{providerTitle} connected. You're ready to chat.
{title} connected. You're ready to chat.
</div>
)
}
@ -694,10 +397,10 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang
return (
<div className="grid gap-3">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{flow.errorMessage || 'Sign-in failed. Try again.'}
{flow.message || 'Sign-in failed. Try again.'}
</div>
<div className="flex justify-end">
<Button onClick={onCancel} variant="outline">
<Button onClick={cancelOnboardingFlow} variant="outline">
Pick a different provider
</Button>
</div>
@ -705,27 +408,23 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang
)
}
if (flow.status === 'awaiting_user' && flow.start?.flow === 'pkce' && flow.provider) {
if (flow.status === 'awaiting_user') {
return (
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {providerTitle}</h3>
<h3 className="text-sm font-semibold">Sign in with {title}</h3>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>We opened {providerTitle} in your browser.</li>
<li>We opened {title} in your browser.</li>
<li>Authorize Hermes there.</li>
<li>Copy the authorization code and paste it below.</li>
</ol>
</div>
<Input
autoFocus
onChange={event => onSubmitCodeChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
onSubmitCode()
}
}}
onChange={event => setOnboardingCode(event.target.value)}
onKeyDown={event => event.key === 'Enter' && void submitOnboardingCode(ctx)}
placeholder="Paste authorization code"
value={flow.submitCode || ''}
value={flow.code}
/>
<div className="flex items-center justify-between gap-3">
<Button asChild size="xs" variant="ghost">
@ -735,10 +434,10 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang
</a>
</Button>
<div className="flex gap-2">
<Button onClick={onCancel} variant="ghost">
<Button onClick={cancelOnboardingFlow} variant="ghost">
Cancel
</Button>
<Button disabled={!(flow.submitCode || '').trim()} onClick={onSubmitCode}>
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
Continue
</Button>
</div>
@ -747,44 +446,48 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang
)
}
if (flow.status === 'polling' && flow.start?.flow === 'device_code' && flow.provider) {
return (
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {providerTitle}</h3>
<p className="mt-1 text-sm text-muted-foreground">
We opened {providerTitle} in your browser. Enter this code there:
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className="font-mono text-2xl tracking-[0.4em]">{flow.start.user_code}</code>
<Button onClick={onCopyCode} size="sm" variant="outline">
{flow.copyState === 'copied' ? <Check className="size-4" /> : 'Copy'}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<Button asChild size="xs" variant="ghost">
<a href={flow.start.verification_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
Re-open verification page
</a>
</Button>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</div>
<Button onClick={onCancel} size="sm" variant="ghost">
Cancel
</Button>
</div>
</div>
)
if (flow.status !== 'polling') {
return null
}
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Working...
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
We opened {title} in your browser. Enter this code there:
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className="font-mono text-2xl tracking-[0.4em]">{flow.start.user_code}</code>
<Button onClick={() => void copyDeviceCode()} size="sm" variant="outline">
{flow.copied ? <Check className="size-4" /> : 'Copy'}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<Button asChild size="xs" variant="ghost">
<a href={flow.start.verification_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
Re-open verification page
</a>
</Button>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</div>
<Button onClick={cancelOnboardingFlow} size="sm" variant="ghost">
Cancel
</Button>
</div>
</div>
)
}
function Status({ children, icon }: { children: React.ReactNode; icon: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
{icon}
{children}
</div>
)
}

View file

@ -1,25 +1,305 @@
import { atom } from 'nanostores'
interface DesktopOnboardingState {
import {
cancelOAuthSession,
listOAuthProviders,
pollOAuthSession,
setEnvVar,
startOAuthLogin,
submitOAuthCode
} from '@/hermes'
import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
export type OnboardingMode = 'apikey' | 'oauth'
export type OnboardingFlow =
| { status: 'idle' }
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'pkce' }>; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'device_code' }>; status: 'polling' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { provider: OAuthProvider; status: 'success' }
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
export interface DesktopOnboardingState {
configured: boolean
flow: OnboardingFlow
mode: OnboardingMode
providers: null | OAuthProvider[]
reason: null | string
requested: boolean
}
export const $desktopOnboarding = atom<DesktopOnboardingState>({
const INITIAL: DesktopOnboardingState = {
configured: true,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false
})
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
export interface OnboardingContext {
onCompleted?: () => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
const POLL_MS = 2000
const BUSY: ReadonlySet<OnboardingFlow['status']> = new Set(['starting', 'awaiting_user', 'polling', 'submitting'])
let pollTimer: number | null = null
function patch(update: Partial<DesktopOnboardingState>) {
$desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
}
function setFlow(flow: OnboardingFlow) {
patch({ flow })
}
function clearPoll() {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
function errMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
async function checkRuntime(ctx: OnboardingContext) {
const [status, runtime] = await Promise.all([
ctx.requestGateway<{ provider_configured?: boolean }>('setup.status').catch(
() => ({}) as { provider_configured?: boolean }
),
ctx
.requestGateway<{ error?: string; ok?: boolean }>('setup.runtime_check')
.catch(() => ({ ok: false }) as { error?: string; ok?: boolean })
])
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
}
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
$desktopOnboarding.set({
reason,
requested: true
})
patch({ reason, requested: true })
}
export function completeDesktopOnboarding() {
$desktopOnboarding.set({
reason: null,
requested: false
})
clearPoll()
$desktopOnboarding.set({ ...INITIAL, configured: true })
}
export function setOnboardingMode(mode: OnboardingMode) {
patch({ mode })
}
export async function refreshOnboarding(ctx: OnboardingContext) {
const ok = await checkRuntime(ctx)
if (ok) {
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
patch({ configured: false })
if ($desktopOnboarding.get().providers !== null) {
return false
}
try {
const { providers } = await listOAuthProviders()
patch({
providers,
mode: providers.length > 0 ? 'oauth' : 'apikey'
})
} catch {
patch({ providers: [], mode: 'apikey' })
}
return false
}
async function finalize(provider: OAuthProvider, ctx: OnboardingContext) {
clearPoll()
setFlow({ status: 'success', provider })
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider.name} connected.` })
await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx)
if (ok) {
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
})
}
}
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
if (provider.flow === 'external') {
notify({
kind: 'info',
title: `${provider.name} uses an external CLI`,
message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.`
})
return
}
clearPoll()
setFlow({ status: 'starting', provider })
try {
const start = await startOAuthLogin(provider.id)
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
if (start.flow === 'pkce') {
setFlow({ status: 'awaiting_user', provider, start, code: '' })
return
}
setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
}
}
async function pollDevice(
provider: OAuthProvider,
start: Extract<OAuthStartResponse, { flow: 'device_code' }>,
ctx: OnboardingContext
) {
try {
const resp = await pollOAuthSession(provider.id, start.session_id)
if (resp.status === 'approved') {
await finalize(provider, ctx)
} else if (resp.status !== 'pending') {
clearPoll()
setFlow({
status: 'error',
provider,
start,
message: resp.error_message || `Sign-in ${resp.status}.`
})
}
} catch (error) {
clearPoll()
setFlow({ status: 'error', provider, start, message: `Polling failed: ${errMessage(error)}` })
}
}
export function setOnboardingCode(code: string) {
const { flow } = $desktopOnboarding.get()
if (flow.status === 'awaiting_user') {
setFlow({ ...flow, code })
}
}
export async function submitOnboardingCode(ctx: OnboardingContext) {
const { flow } = $desktopOnboarding.get()
if (flow.status !== 'awaiting_user' || !flow.code.trim()) {
return
}
const { provider, start, code } = flow
setFlow({ status: 'submitting', provider, start })
try {
const resp = await submitOAuthCode(provider.id, start.session_id, code.trim())
if (resp.ok && resp.status === 'approved') {
await finalize(provider, ctx)
} else {
setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' })
}
} catch (error) {
setFlow({ status: 'error', provider, start, message: errMessage(error) })
}
}
export function cancelOnboardingFlow() {
const { flow } = $desktopOnboarding.get()
clearPoll()
const sessionId =
flow.status === 'awaiting_user' || flow.status === 'polling' || flow.status === 'submitting'
? flow.start?.session_id
: flow.status === 'error'
? flow.start?.session_id
: undefined
if (sessionId) {
cancelOAuthSession(sessionId).catch(() => undefined)
}
setFlow({ status: 'idle' })
}
export async function copyDeviceCode() {
const { flow } = $desktopOnboarding.get()
if (flow.status !== 'polling') {
return
}
try {
await navigator.clipboard.writeText(flow.start.user_code)
setFlow({ ...flow, copied: true })
window.setTimeout(() => {
const current = $desktopOnboarding.get().flow
if (current.status === 'polling' && current.start.session_id === flow.start.session_id) {
setFlow({ ...current, copied: false })
}
}, 1500)
} catch {
// Clipboard write blocked — user can still type the code from the dialog.
}
}
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
try {
await setEnvVar(envKey, trimmed)
await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx)
if (ok) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${label} connected.` })
completeDesktopOnboarding()
ctx.onCompleted?.()
return { ok: true }
}
return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
} catch (error) {
notifyError(error, `Could not save ${label}`)
return { ok: false, message: errMessage(error) }
}
}
export function isOnboardingBusy(flow: OnboardingFlow) {
return BUSY.has(flow.status)
}