refactor(desktop): tighten onboarding store + overlay

Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save.

In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows.
This commit is contained in:
Brooklyn Nicholson
2026-05-07 23:58:12 -04:00
parent da6b745fff
commit 11d04d9d5e
2 changed files with 213 additions and 255 deletions
@@ -38,6 +38,8 @@ interface ApiKeyOption {
short?: string short?: string
} }
const MIN_KEY_LENGTH = 8
const API_KEY_OPTIONS: ApiKeyOption[] = [ const API_KEY_OPTIONS: ApiKeyOption[] = [
{ {
id: 'openrouter', id: 'openrouter',
@@ -97,17 +99,11 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
external: 'Sign in once in your terminal, then come back to chat.' external: 'Sign in once in your terminal, then come back to chat.'
} }
function providerTitle(provider: OAuthProvider) { const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
return PROVIDER_DISPLAY[provider.id]?.title ?? provider.name const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
}
function sortProviders(providers: OAuthProvider[]) { const sortProviders = (providers: OAuthProvider[]) =>
return [...providers].sort((a, b) => { [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
const order = (PROVIDER_DISPLAY[a.id]?.order ?? 99) - (PROVIDER_DISPLAY[b.id]?.order ?? 99)
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 onboarding = useStore($desktopOnboarding)
@@ -124,28 +120,23 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
) )
useEffect(() => { useEffect(() => {
if (!enabled && !onboarding.requested) { if (enabled || onboarding.requested) {
return void refreshOnboarding(ctx)
} }
void refreshOnboarding(ctx)
}, [ctx, enabled, onboarding.requested]) }, [ctx, enabled, onboarding.requested])
if (!visible) { if (!visible) {
return null return null
} }
const { flow } = onboarding
const showPicker = flow.status === 'idle' || flow.status === 'success'
return ( return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl"> <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="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<Header /> <Header />
<div className="grid gap-5 p-6"> <div className="grid gap-5 p-6">{showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} />}</div>
{onboarding.flow.status === 'idle' || onboarding.flow.status === 'success' ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={onboarding.flow} />
)}
</div>
</div> </div>
</div> </div>
) )
@@ -181,18 +172,18 @@ function Picker({ ctx }: { ctx: OnboardingContext }) {
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
{providers === null ? ( {providers === null ? (
<Status icon={<Loader2 className="size-4 animate-spin" />}>Looking up providers...</Status> <Status>Looking up providers...</Status>
) : ( ) : (
ordered.map(provider => ( ordered.map(provider => (
<ProviderRow key={provider.id} onSelect={p => void startProviderOAuth(p, ctx)} provider={provider} /> <ProviderRow key={provider.id} onSelect={p => void startProviderOAuth(p, ctx)} provider={provider} />
)) ))
)} )}
<ModeSwitchLink onClick={() => setOnboardingMode('apikey')}>I have an API key</ModeSwitchLink> <FooterLink onClick={() => setOnboardingMode('apikey')}>I have an API key</FooterLink>
</div> </div>
) )
} }
function ModeSwitchLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { function FooterLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return ( return (
<div className="pt-2 text-center"> <div className="pt-2 text-center">
<button <button
@@ -206,14 +197,7 @@ function ModeSwitchLink({ children, onClick }: { children: React.ReactNode; onCl
) )
} }
function ProviderRow({ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
provider,
onSelect
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const title = providerTitle(provider)
const loggedIn = provider.status?.logged_in const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight
@@ -228,7 +212,7 @@ function ProviderRow({
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold">{title}</span> <span className="text-sm font-semibold">{providerTitle(provider)}</span>
{loggedIn ? ( {loggedIn ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> <span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" /> <Check className="size-3" />
@@ -250,7 +234,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
const [error, setError] = useState<null | string>(null) const [error, setError] = useState<null | string>(null)
const isLocal = option.envKey === 'OPENAI_BASE_URL' const isLocal = option.envKey === 'OPENAI_BASE_URL'
const canSave = value.trim().length >= (isLocal ? 1 : 8) const canSave = value.trim().length >= (isLocal ? 1 : MIN_KEY_LENGTH)
const submit = async () => { const submit = async () => {
if (!canSave || saving) { if (!canSave || saving) {
@@ -261,10 +245,10 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
setError(null) setError(null)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx) const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
if (!result.ok) { if (result.ok) {
setError(result.message ?? 'Could not save credential.')
} else {
setValue('') setValue('')
} else {
setError(result.message ?? 'Could not save credential.')
} }
setSaving(false) setSaving(false)
@@ -282,6 +266,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
Back to sign in Back to sign in
</button> </button>
) : null} ) : null}
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(o => ( {API_KEY_OPTIONS.map(o => (
<button <button
@@ -309,21 +294,14 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p> <p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? ( {option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
<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> </div>
<Input <Input
autoComplete="off" autoComplete="off"
autoFocus autoFocus
className="font-mono" className="font-mono"
onChange={event => setValue(event.target.value)} onChange={e => setValue(e.target.value)}
onKeyDown={event => event.key === 'Enter' && void submit()} onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={option.placeholder || 'Paste API key'} placeholder={option.placeholder || 'Paste API key'}
type={isLocal ? 'text' : 'password'} type={isLocal ? 'text' : 'password'}
value={value} value={value}
@@ -345,11 +323,11 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : '' const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
if (flow.status === 'starting') { if (flow.status === 'starting') {
return <Status icon={<Loader2 className="size-4 animate-spin" />}>Starting sign-in for {title}...</Status> return <Status>Starting sign-in for {title}...</Status>
} }
if (flow.status === 'submitting') { if (flow.status === 'submitting') {
return <Status icon={<Loader2 className="size-4 animate-spin" />}>Verifying your code with {title}...</Status> return <Status>Verifying your code with {title}...</Status>
} }
if (flow.status === 'success') { if (flow.status === 'success') {
@@ -378,80 +356,45 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'awaiting_user') { if (flow.status === 'awaiting_user') {
return ( return (
<div className="grid gap-4"> <Step title={`Sign in with ${title}`}>
<div> <ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<h3 className="text-sm font-semibold">Sign in with {title}</h3> <li>We opened {title} in your browser.</li>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground"> <li>Authorize Hermes there.</li>
<li>We opened {title} in your browser.</li> <li>Copy the authorization code and paste it below.</li>
<li>Authorize Hermes there.</li> </ol>
<li>Copy the authorization code and paste it below.</li>
</ol>
</div>
<Input <Input
autoFocus autoFocus
onChange={event => setOnboardingCode(event.target.value)} onChange={e => setOnboardingCode(e.target.value)}
onKeyDown={event => event.key === 'Enter' && void submitOnboardingCode(ctx)} onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
placeholder="Paste authorization code" placeholder="Paste authorization code"
value={flow.code} value={flow.code}
/> />
<div className="flex items-center justify-between gap-3"> <FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open authorization page</DocsLink>}>
<Button asChild size="xs" variant="ghost"> <CancelBtn />
<a href={flow.start.auth_url} rel="noreferrer" target="_blank"> <Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
<ExternalLink className="size-3" /> Continue
Re-open authorization page
</a>
</Button> </Button>
<div className="flex gap-2"> </FlowFooter>
<Button onClick={cancelOnboardingFlow} variant="ghost"> </Step>
Cancel
</Button>
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
Continue
</Button>
</div>
</div>
</div>
) )
} }
if (flow.status === 'external_pending') { if (flow.status === 'external_pending') {
return ( return (
<div className="grid gap-4"> <Step title={`Sign in with ${title}`}>
<div> <p className="text-sm text-muted-foreground">
<h3 className="text-sm font-semibold">Sign in with {title}</h3> {title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed
<p className="mt-1 text-sm text-muted-foreground"> in":
{title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed </p>
in": <CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
</p> <FlowFooter left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}>
</div> <CancelBtn />
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3"> <Button onClick={() => void recheckExternalSignin(ctx)}>
<code className="font-mono text-sm">{flow.provider.cli_command}</code> <Check className="size-4" />
<Button onClick={() => void copyExternalCommand()} size="sm" variant="outline"> I've signed in
{flow.copied ? <Check className="size-4" /> : 'Copy'}
</Button> </Button>
</div> </FlowFooter>
<div className="flex items-center justify-between gap-3"> </Step>
{flow.provider.docs_url ? (
<Button asChild size="xs" variant="ghost">
<a href={flow.provider.docs_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{title} docs
</a>
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button onClick={cancelOnboardingFlow} variant="ghost">
Cancel
</Button>
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
I've signed in
</Button>
</div>
</div>
</div>
) )
} }
@@ -460,44 +403,82 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
} }
return ( return (
<div className="grid gap-4"> <Step title={`Sign in with ${title}`}>
<div> <p className="text-sm text-muted-foreground">We opened {title} in your browser. Enter this code there:</p>
<h3 className="text-sm font-semibold">Sign in with {title}</h3> <CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
<p className="mt-1 text-sm text-muted-foreground"> <FlowFooter left={<DocsLink href={flow.start.verification_url}>Re-open verification page</DocsLink>}>
We opened {title} in your browser. Enter this code there: <span className="flex items-center gap-2 text-xs text-muted-foreground">
</p> <Loader2 className="size-3 animate-spin" />
</div> Waiting for you to authorize...
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3"> </span>
<code className="font-mono text-2xl tracking-[0.4em]">{flow.start.user_code}</code> <CancelBtn size="sm" />
<Button onClick={() => void copyDeviceCode()} size="sm" variant="outline"> </FlowFooter>
{flow.copied ? <Check className="size-4" /> : 'Copy'} </Step>
</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-3">
<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>
</div>
) )
} }
function Status({ children, icon }: { children: React.ReactNode; icon: React.ReactNode }) { function Step({ children, title }: { children: React.ReactNode; title: string }) {
return ( return (
<div className="grid gap-4">
<h3 className="text-sm font-semibold">{title}</h3>
{children}
</div>
)
}
function CodeBlock({
copied,
large,
onCopy,
text
}: {
copied: boolean
large?: boolean
onCopy: () => void
text: string
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
<Button onClick={onCopy} size="sm" variant="outline">
{copied ? <Check className="size-4" /> : 'Copy'}
</Button>
</div>
)
}
function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">{left}</div>
<div className="flex items-center gap-3">{children}</div>
</div>
)
}
function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
return (
<Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
Cancel
</Button>
)
}
function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
return (
<Button asChild size="xs" variant="ghost">
<a href={href} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{children}
</a>
</Button>
)
}
function Status({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
{icon} <Loader2 className="size-4 animate-spin" />
{children} {children}
</div> </div>
) )
+88 -111
View File
@@ -11,13 +11,16 @@ import {
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes' import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
export type OnboardingMode = 'apikey' | 'oauth' export type OnboardingMode = 'apikey' | 'oauth'
export type OnboardingFlow = export type OnboardingFlow =
| { status: 'idle' } | { status: 'idle' }
| { provider: OAuthProvider; status: 'starting' } | { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'pkce' }>; status: 'awaiting_user' } | { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'device_code' }>; status: 'polling' } | { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' } | { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' } | { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' } | { provider: OAuthProvider; status: 'success' }
@@ -32,6 +35,11 @@ export interface DesktopOnboardingState {
requested: boolean requested: boolean
} }
export interface OnboardingContext {
onCompleted?: () => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
const INITIAL: DesktopOnboardingState = { const INITIAL: DesktopOnboardingState = {
configured: true, configured: true,
flow: { status: 'idle' }, flow: { status: 'idle' },
@@ -41,32 +49,21 @@ const INITIAL: DesktopOnboardingState = {
requested: false 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 POLL_MS = 2000
const COPY_FLASH_MS = 1500
const BUSY: ReadonlySet<OnboardingFlow['status']> = new Set([ export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
'starting',
'awaiting_user',
'polling',
'submitting',
'external_pending'
])
let pollTimer: number | null = null let pollTimer: number | null = null
function patch(update: Partial<DesktopOnboardingState>) { const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
$desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
}
function setFlow(flow: OnboardingFlow) { const patch = (update: Partial<DesktopOnboardingState>) =>
patch({ flow }) $desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
}
const setFlow = (flow: OnboardingFlow) => patch({ flow })
const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined)
function clearPoll() { function clearPoll() {
if (pollTimer !== null) { if (pollTimer !== null) {
@@ -75,23 +72,40 @@ function clearPoll() {
} }
} }
function errMessage(error: unknown) { async function safeReq<T>(ctx: OnboardingContext, method: string, fallback: T): Promise<T> {
return error instanceof Error ? error.message : String(error) try {
return await ctx.requestGateway<T>(method)
} catch {
return fallback
}
} }
async function checkRuntime(ctx: OnboardingContext) { async function checkRuntime(ctx: OnboardingContext) {
const [status, runtime] = await Promise.all([ const [status, runtime] = await Promise.all([
ctx.requestGateway<{ provider_configured?: boolean }>('setup.status').catch( safeReq<{ provider_configured?: boolean }>(ctx, 'setup.status', {}),
() => ({}) as { provider_configured?: boolean } safeReq<{ error?: string; ok?: boolean }>(ctx, 'setup.runtime_check', { ok: false })
),
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) return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
} }
function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
}
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: () => void) {
await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx)
if (ok) {
notifyReady(providerName)
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
onFail()
}
}
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') { export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
patch({ reason, requested: true }) patch({ reason, requested: true })
} }
@@ -106,9 +120,7 @@ export function setOnboardingMode(mode: OnboardingMode) {
} }
export async function refreshOnboarding(ctx: OnboardingContext) { export async function refreshOnboarding(ctx: OnboardingContext) {
const ok = await checkRuntime(ctx) if (await checkRuntime(ctx)) {
if (ok) {
completeDesktopOnboarding() completeDesktopOnboarding()
ctx.onCompleted?.() ctx.onCompleted?.()
@@ -123,10 +135,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
try { try {
const { providers } = await listOAuthProviders() const { providers } = await listOAuthProviders()
patch({ patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
providers,
mode: providers.length > 0 ? 'oauth' : 'apikey'
})
} catch { } catch {
patch({ providers: [], mode: 'apikey' }) patch({ providers: [], mode: 'apikey' })
} }
@@ -134,34 +143,15 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false 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) { export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
clearPoll()
if (provider.flow === 'external') { if (provider.flow === 'external') {
clearPoll()
setFlow({ status: 'external_pending', provider, copied: false }) setFlow({ status: 'external_pending', provider, copied: false })
return return
} }
clearPoll()
setFlow({ status: 'starting', provider }) setFlow({ status: 'starting', provider })
try { try {
@@ -181,24 +171,23 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
} }
} }
async function pollDevice( async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
provider: OAuthProvider,
start: Extract<OAuthStartResponse, { flow: 'device_code' }>,
ctx: OnboardingContext
) {
try { try {
const resp = await pollOAuthSession(provider.id, start.session_id) const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
if (resp.status === 'approved') { if (status === 'approved') {
await finalize(provider, ctx)
} else if (resp.status !== 'pending') {
clearPoll() clearPoll()
setFlow({ setFlow({ status: 'success', provider })
status: 'error', await reloadAndConnect(ctx, provider.name, () =>
provider, setFlow({
start, status: 'error',
message: resp.error_message || `Sign-in ${resp.status}.` provider,
}) message: 'Connected, but Hermes still cannot resolve a usable provider.'
})
)
} else if (status !== 'pending') {
clearPoll()
setFlow({ status: 'error', provider, start, message: error_message || `Sign-in ${status}.` })
} }
} catch (error) { } catch (error) {
clearPoll() clearPoll()
@@ -228,7 +217,14 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
const resp = await submitOAuthCode(provider.id, start.session_id, code.trim()) const resp = await submitOAuthCode(provider.id, start.session_id, code.trim())
if (resp.ok && resp.status === 'approved') { if (resp.ok && resp.status === 'approved') {
await finalize(provider, ctx) setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () =>
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
})
)
} else { } else {
setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' }) setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' })
} }
@@ -238,15 +234,8 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
} }
export function cancelOnboardingFlow() { export function cancelOnboardingFlow() {
const { flow } = $desktopOnboarding.get()
clearPoll() clearPoll()
const sessionId = sessionIdFor($desktopOnboarding.get().flow)
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) { if (sessionId) {
cancelOAuthSession(sessionId).catch(() => undefined) cancelOAuthSession(sessionId).catch(() => undefined)
@@ -275,7 +264,7 @@ async function copyAndFlash(text: string, predicate: (flow: OnboardingFlow) => b
if (predicate(current) && 'copied' in current) { if (predicate(current) && 'copied' in current) {
setFlow({ ...current, copied: false }) setFlow({ ...current, copied: false })
} }
}, 1500) }, COPY_FLASH_MS)
} }
export async function copyDeviceCode() { export async function copyDeviceCode() {
@@ -285,10 +274,8 @@ export async function copyDeviceCode() {
return return
} }
await copyAndFlash( const sid = flow.start.session_id
flow.start.user_code, await copyAndFlash(flow.start.user_code, f => f.status === 'polling' && f.start.session_id === sid)
f => f.status === 'polling' && f.start.session_id === flow.start.session_id
)
} }
export async function copyExternalCommand() { export async function copyExternalCommand() {
@@ -298,7 +285,8 @@ export async function copyExternalCommand() {
return return
} }
await copyAndFlash(flow.provider.cli_command, f => f.status === 'external_pending' && f.provider.id === flow.provider.id) const id = flow.provider.id
await copyAndFlash(flow.provider.cli_command, f => f.status === 'external_pending' && f.provider.id === id)
} }
export async function recheckExternalSignin(ctx: OnboardingContext) { export async function recheckExternalSignin(ctx: OnboardingContext) {
@@ -308,19 +296,14 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
return return
} }
const ok = await checkRuntime(ctx) const { provider } = flow
await reloadAndConnect(ctx, provider.name, () =>
if (ok) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${flow.provider.name} connected.` })
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
setFlow({ setFlow({
status: 'error', status: 'error',
provider: flow.provider, provider,
message: `Hermes still cannot reach ${flow.provider.name}. Run \`${flow.provider.cli_command}\` in a terminal first.` message: `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
}) })
} )
} }
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) { export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
@@ -332,25 +315,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
try { try {
await setEnvVar(envKey, trimmed) await setEnvVar(envKey, trimmed)
await ctx.requestGateway('reload.env').catch(() => undefined) let stillFailing = false
const ok = await checkRuntime(ctx) await reloadAndConnect(ctx, label, () => {
stillFailing = true
})
if (ok) { if (stillFailing) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${label} connected.` }) return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
completeDesktopOnboarding()
ctx.onCompleted?.()
return { ok: true }
} }
return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` } return { ok: true }
} catch (error) { } catch (error) {
notifyError(error, `Could not save ${label}`) notifyError(error, `Could not save ${label}`)
return { ok: false, message: errMessage(error) } return { ok: false, message: errMessage(error) }
} }
} }
export function isOnboardingBusy(flow: OnboardingFlow) {
return BUSY.has(flow.status)
}