From 37d1c57f8a0b3f0a82f2a06a5b884f6059ae2aac Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 7 May 2026 23:43:51 -0400 Subject: [PATCH] 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. --- .../components/desktop-onboarding-overlay.tsx | 825 ++++++------------ apps/desktop/src/store/onboarding.ts | 302 ++++++- 2 files changed, 555 insertions(+), 572 deletions(-) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 1d6eb6820e..ab39f70e22 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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: (method: string, params?: Record) => Promise -} - -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 = { +const PROVIDER_DISPLAY: Record = { 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 = { + 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(null) - const [mode, setMode] = useState<'apikey' | 'oauth'>('oauth') - const [flow, setFlow] = useState({ status: 'idle' }) - const [apiKeyOption, setApiKeyOption] = useState(API_KEY_OPTIONS[0]) - const [apiKeyValue, setApiKeyValue] = useState('') - const [apiKeySaving, setApiKeySaving] = useState(false) - const [apiKeyError, setApiKeyError] = useState(null) - const cancelledRef = useRef(false) - const pollTimerRef = useRef(null) - const shouldShow = enabled || onboarding.requested + const visible = (enabled || onboarding.requested) && !onboarding.configured + const ctxRef = useRef({ requestGateway, onCompleted }) + ctxRef.current = { requestGateway, onCompleted } - const refreshSetupCheck = useMemo( - () => async () => { - try { - const [status, runtime] = await Promise.all([ - requestGateway('setup.status').catch(() => ({}) as SetupStatus), - requestGateway('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( + () => ({ + 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 (
-
-
-
- -
-
-

Welcome to Hermes

-

- Connect a model provider to start chatting. Most options take one click. -

-
-
-
- +
- {flow.status === 'idle' || flow.status === 'success' ? ( - void saveApiKey()} - onApiKeySelect={setApiKeyOption} - onApiKeyValueChange={setApiKeyValue} - onModeChange={setMode} - onProviderSelect={provider => void startProviderFlow(provider)} - /> + {onboarding.flow.status === 'idle' || onboarding.flow.status === 'success' ? ( + ) : ( - void copyDeviceCode()} - onSubmitCode={() => void submitPkce()} - onSubmitCodeChange={code => setFlow(prev => ({ ...prev, submitCode: code }))} - /> + )}
@@ -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 ( +
+
+
+ +
+
+

Welcome to Hermes

+

+ Connect a model provider to start chatting. Most options take one click. +

+
+
+
+ ) } -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 && ( -
- - -
+ )} {mode === 'oauth' && hasOauth ? (
- {loading ? ( -
- - Looking up providers... -
+ {providers === null ? ( + }>Looking up providers... ) : ( - oauthProviders.map(provider => ( - + ordered.map(provider => ( + void startProviderOAuth(p, ctx)} provider={provider} /> )) )}
) : ( -
-
- {API_KEY_OPTIONS.map(option => ( - - ))} -
- -
-
-

{apiKeyOption.description}

- {apiKeyOption.docsUrl ? ( - - ) : null} -
- 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 ?

{apiKeyError}

: null} -
- -
- -
-
+ )} ) } +interface ModeTab { + id: T + label: string +} + +interface ModeTabsProps { + mode: T + onChange: (mode: T) => void + tabs: ModeTab[] +} + +const TAB_COLS: Record = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4' +} + +function ModeTabs({ mode, onChange, tabs }: ModeTabsProps) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} + 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 (
-

{flowSubtitle(provider.flow, title)}

+

{FLOW_SUBTITLES[provider.flow]}

- {isExternal ? ( - - ) : ( - - )} + ) } -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(API_KEY_OPTIONS[0]) + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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 ( +
+
+ {API_KEY_OPTIONS.map(o => ( + + ))} +
- return 'Sign in once in your terminal, then come back to chat.' +
+
+

{option.description}

+ {option.docsUrl ? ( + + ) : null} +
+ setValue(event.target.value)} + onKeyDown={event => event.key === 'Enter' && void submit()} + placeholder={option.placeholder || 'Paste API key'} + type={isLocal ? 'text' : 'password'} + value={value} + /> + {error ?

{error}

: null} +
+ +
+ +
+
+ ) } -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 ( -
- - Starting sign-in for {providerTitle}... -
- ) + return }>Starting sign-in for {title}... } if (flow.status === 'submitting') { - return ( -
- - Verifying your code with {providerTitle}... -
- ) + return }>Verifying your code with {title}... } if (flow.status === 'success') { return (
- {providerTitle} connected. You're ready to chat. + {title} connected. You're ready to chat.
) } @@ -694,10 +397,10 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang return (
- {flow.errorMessage || 'Sign-in failed. Try again.'} + {flow.message || 'Sign-in failed. Try again.'}
-
@@ -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 (
-

Sign in with {providerTitle}

+

Sign in with {title}

    -
  1. We opened {providerTitle} in your browser.
  2. +
  3. We opened {title} in your browser.
  4. Authorize Hermes there.
  5. Copy the authorization code and paste it below.
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} />
- -
@@ -747,44 +446,48 @@ function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChang ) } - if (flow.status === 'polling' && flow.start?.flow === 'device_code' && flow.provider) { - return ( -
-
-

Sign in with {providerTitle}

-

- We opened {providerTitle} in your browser. Enter this code there: -

-
-
- {flow.start.user_code} - -
-
- -
- - Waiting for you to authorize... -
- -
-
- ) + if (flow.status !== 'polling') { + return null } return ( -
- - Working... +
+
+

Sign in with {title}

+

+ We opened {title} in your browser. Enter this code there: +

+
+
+ {flow.start.user_code} + +
+
+ +
+ + Waiting for you to authorize... +
+ +
+
+ ) +} + +function Status({ children, icon }: { children: React.ReactNode; icon: React.ReactNode }) { + return ( +
+ {icon} + {children}
) } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index b291ec1f21..249f71d74f 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -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; status: 'awaiting_user' } + | { copied: boolean; provider: OAuthProvider; start: Extract; 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({ +const INITIAL: DesktopOnboardingState = { + configured: true, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, reason: null, requested: false -}) +} + +export const $desktopOnboarding = atom(INITIAL) + +export interface OnboardingContext { + onCompleted?: () => void + requestGateway: (method: string, params?: Record) => Promise +} + +const POLL_MS = 2000 +const BUSY: ReadonlySet = new Set(['starting', 'awaiting_user', 'polling', 'submitting']) + +let pollTimer: number | null = null + +function patch(update: Partial) { + $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, + 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) }