hermes-agent/apps/desktop/src/components/onboarding/flow.tsx
Brooklyn Nicholson 18d54bf0fd refactor(desktop): split onboarding overlay god file into onboarding/ folder
desktop-onboarding-overlay.tsx (1,291 lines) folded into components/onboarding/
as a cohesive feature folder. Behaviour-preserving — every move is verbatim;
typecheck + lint + tests green.

- index.tsx (665) — overlay shell, Picker, Header/Preparing, API-key catalog +
  ApiKeyForm; re-exports the provider API the settings page consumes.
- flow.tsx (364) — OAuth flow panels (FlowPanel, steps, DeviceCode, CodeBlock,
  ConfirmingModelPanel, DocsLink, Status).
- providers.tsx (118) — provider rows + display/sort (FeaturedProviderRow,
  ProviderRow, KeyProviderRow, providerTitle, sortProviders).
- glyph.tsx (170) — the decode/scramble animation toolkit (pure leaf).

Importers (desktop-controller, providers-settings) repointed to
@/components/onboarding; the overlay test moved to onboarding/index.test.tsx.
2026-06-30 13:21:49 -05:00

364 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { ExternalLink, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
cancelOnboardingFlow,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
recheckExternalSignin,
setOnboardingCode,
setOnboardingModel,
submitOnboardingCode
} from '@/store/onboarding'
import { DecodedLabel, GlyphText, HackeryButton, useScramble } from './glyph'
import { providerTitle } from './providers'
export function FlowPanel({
ctx,
flow,
leaving,
onBegin
}: {
ctx: OnboardingContext
flow: OnboardingFlow
leaving: boolean
onBegin: () => void
}) {
const { t } = useI18n()
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
if (flow.status === 'starting') {
return <Status>{t.onboarding.startingSignIn(title)}</Status>
}
if (flow.status === 'submitting') {
return <Status>{t.onboarding.verifyingCode(title)}</Status>
}
if (flow.status === 'success') {
return <DecodedLabel text={t.onboarding.connectedPicking(title)} />
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
}
if (flow.status === 'error') {
return (
<div className="grid gap-3">
<div className="flex items-center gap-1.5 text-sm text-destructive">
<ErrorIcon className="shrink-0" size="0.875rem" />
<span>{flow.message || t.onboarding.signInFailed}</span>
</div>
<div className="flex justify-end">
<Button onClick={cancelOnboardingFlow} variant="outline">
{t.onboarding.pickDifferentProvider}
</Button>
</div>
</div>
)
}
if (flow.status === 'awaiting_user') {
return (
<Step title={t.onboarding.signInWith(title)}>
<ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>{t.onboarding.openedBrowser(title)}</li>
<li>{t.onboarding.authorizeThere}</li>
<li>{t.onboarding.copyAuthCode}</li>
</ol>
<Input
autoFocus
onChange={e => setOnboardingCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
placeholder={t.onboarding.pasteAuthCode}
value={flow.code}
/>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenAuthPage}</DocsLink>}>
<CancelBtn />
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
{t.common.continue}
</Button>
</FlowFooter>
</Step>
)
}
if (flow.status === 'awaiting_browser') {
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t.onboarding.waitingAuthorize}
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}
if (flow.status === 'external_pending') {
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.externalPending(title)}</p>
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
<FlowFooter
left={
flow.provider.docs_url ? (
<DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink>
) : null
}
>
<CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}</Button>
</FlowFooter>
</Step>
)
}
if (flow.status !== 'polling') {
return null
}
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
<DeviceCode code={flow.start.user_code} copied={flow.copied} onCopy={() => void copyDeviceCode()} />
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t.onboarding.waitingAuthorize}
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}
function Step({ children, title }: { children: React.ReactNode; title: string }) {
return (
<div className="grid gap-4">
<h3 className="text-sm font-semibold">{title}</h3>
{children}
</div>
)
}
// Device-code display: OTP-style — each character in its own readonly cell.
// The whole row is the copy button (no side button, no checkmark); on copy the
// cells flash emerald for feedback. Dashes render as quiet separators.
function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
const { t } = useI18n()
return (
<button
aria-label={t.onboarding.copy}
className="group flex w-full items-center justify-center gap-1.5"
onClick={onCopy}
type="button"
>
{[...code].map((ch, i) =>
ch === '-' || ch === ' ' ? (
<span className="w-1.5 text-center text-lg text-muted-foreground" key={i}>
</span>
) : (
<span
className={cn(
'flex size-10 items-center justify-center rounded-md border font-mono text-xl font-semibold uppercase transition-colors',
copied
? 'border-primary/50 text-primary'
: 'border-(--stroke-nous) text-foreground group-hover:border-(--ui-stroke-secondary)'
)}
key={i}
>
{ch}
</span>
)
)}
</button>
)
}
function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
const { t } = useI18n()
return (
<div className="flex items-center justify-between gap-3 rounded-md border border-(--stroke-nous) px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-sm">
<span className="mr-2 select-none text-muted-foreground">$</span>
{text}
</code>
<Button onClick={onCopy} size="sm" variant="outline">
{copied ? t.common.copied : t.onboarding.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' }) {
const { t } = useI18n()
return (
<Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
{t.common.cancel}
</Button>
)
}
function ConfirmingModelPanel({
flow,
leaving,
onBegin
}: {
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
leaving: boolean
onBegin: () => void
}) {
const { t } = useI18n()
const scrambledModel = useScramble(flow.currentModel, leaving)
const scrambledBegin = useScramble(t.onboarding.startChatting, leaving)
// Local state controls whether the model picker dialog is open.
// We reuse the existing ModelPickerDialog component (the same picker
// available from the chat shell) rather than building an inline
// dropdown — gives us search, multi-provider listing if relevant, and
// a familiar UI for users who'll see this picker again later.
const [pickerOpen, setPickerOpen] = useState(false)
// Pull pricing + tier for the just-picked default so the confirm card
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
const options = useQuery({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
})
const providerRow = options.data?.providers?.find(
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
)
const price = providerRow?.pricing?.[flow.currentModel]
const freeTier = providerRow?.free_tier
return (
<div className="grid place-items-center gap-7 py-6 text-center">
<DecodedLabel leaving={leaving} text={t.onboarding.connectedProvider(flow.label)} />
<div
className={cn(
'grid justify-items-center gap-1.5 transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">
{t.onboarding.defaultModel}
</span>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
{t.onboarding.pro}
</span>
)}
</div>
<p className="font-mono text-base">
<GlyphText text={scrambledModel} />
</p>
{price && (price.input || price.output) && (
<p className="font-mono text-xs text-muted-foreground">
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
<Button
className="mt-0.5 text-xs"
disabled={flow.saving}
onClick={() => setPickerOpen(true)}
size="inline"
variant="text"
>
{t.onboarding.change}
</Button>
</div>
<div
className={cn(
'transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<HackeryButton
disabled={flow.saving}
label={<GlyphText text={scrambledBegin} />}
loading={flow.saving}
onClick={onBegin}
/>
</div>
{/*
ModelPickerDialog defaults to z-130 on its content, which renders
UNDER the onboarding overlay (z-1300) and breaks pointer events.
Bump it above with z-[1310] so the picker sits on top of the
onboarding panel. The dialog's own dim-backdrop layer stays at
its default z-120 — the onboarding overlay is already dimming
the rest of the screen, so we don't want a second backdrop.
*/}
<ModelPickerDialog
contentClassName="z-[1310]"
currentModel={flow.currentModel}
currentProvider={flow.providerSlug}
onOpenChange={setPickerOpen}
onSelect={({ model }) => {
void setOnboardingModel(model)
setPickerOpen(false)
}}
open={pickerOpen}
/>
</div>
)
}
export function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
return (
<Button asChild size="xs" variant="text">
<a href={href} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{children}
</a>
</Button>
)
}
export function Status({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2.5 py-1 text-sm text-muted-foreground" role="status">
<Loader className="size-7" type="lemniscate-bloom" />
{children}
</div>
)
}