diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 07d57ac49..29f34b958 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -5,8 +5,8 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
-import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
+import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { Pane, PaneMain } from '@/components/pane-shell'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { useMediaQuery } from '@/hooks/use-media-query'
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
index d06280902..10c9619d6 100644
--- a/apps/desktop/src/app/settings/providers-settings.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -10,7 +10,7 @@ import {
ProviderRow,
providerTitle,
sortProviders
-} from '@/components/desktop-onboarding-overlay'
+} from '@/components/onboarding'
import { Button } from '@/components/ui/button'
import { RowButton } from '@/components/ui/row-button'
import { SearchField } from '@/components/ui/search-field'
diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx
new file mode 100644
index 000000000..11cb3073a
--- /dev/null
+++ b/apps/desktop/src/components/onboarding/flow.tsx
@@ -0,0 +1,364 @@
+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 {t.onboarding.startingSignIn(title)}
+ }
+
+ if (flow.status === 'submitting') {
+ return {t.onboarding.verifyingCode(title)}
+ }
+
+ if (flow.status === 'success') {
+ return
+ }
+
+ if (flow.status === 'confirming_model') {
+ return
+ }
+
+ if (flow.status === 'error') {
+ return (
+
+
+
+ {flow.message || t.onboarding.signInFailed}
+
+
+
+ {t.onboarding.pickDifferentProvider}
+
+
+
+ )
+ }
+
+ if (flow.status === 'awaiting_user') {
+ return (
+
+
+ {t.onboarding.openedBrowser(title)}
+ {t.onboarding.authorizeThere}
+ {t.onboarding.copyAuthCode}
+
+ setOnboardingCode(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
+ placeholder={t.onboarding.pasteAuthCode}
+ value={flow.code}
+ />
+ {t.onboarding.reopenAuthPage}}>
+
+ void submitOnboardingCode(ctx)}>
+ {t.common.continue}
+
+
+
+ )
+ }
+
+ if (flow.status === 'awaiting_browser') {
+ return (
+
+ {t.onboarding.autoBrowser(title)}
+ {t.onboarding.reopenSignInPage}}>
+
+
+ {t.onboarding.waitingAuthorize}
+
+
+
+
+ )
+ }
+
+ if (flow.status === 'external_pending') {
+ return (
+
+ {t.onboarding.externalPending(title)}
+ void copyExternalCommand()} text={flow.provider.cli_command} />
+ {t.onboarding.docs(title)}
+ ) : null
+ }
+ >
+
+ void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}
+
+
+ )
+ }
+
+ if (flow.status !== 'polling') {
+ return null
+ }
+
+ return (
+
+ {t.onboarding.deviceCodeOpened(title)}
+ void copyDeviceCode()} />
+ {t.onboarding.reopenVerification}}>
+
+
+ {t.onboarding.waitingAuthorize}
+
+
+
+
+ )
+}
+
+function Step({ children, title }: { children: React.ReactNode; title: string }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
+// 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 (
+
+ {[...code].map((ch, i) =>
+ ch === '-' || ch === ' ' ? (
+
+ β
+
+ ) : (
+
+ {ch}
+
+ )
+ )}
+
+ )
+}
+
+function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
+ const { t } = useI18n()
+
+ return (
+
+
+ $
+ {text}
+
+
+ {copied ? t.common.copied : t.onboarding.copy}
+
+
+ )
+}
+
+function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
+ const { t } = useI18n()
+
+ return (
+
+ {t.common.cancel}
+
+ )
+}
+
+function ConfirmingModelPanel({
+ flow,
+ leaving,
+ onBegin
+}: {
+ flow: Extract
+ 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 (
+
+
+
+
+
+
+ {t.onboarding.defaultModel}
+
+ {freeTier === true && (
+
+ {t.onboarding.freeTier}
+
+ )}
+ {freeTier === false && (
+
+ {t.onboarding.pro}
+
+ )}
+
+
+
+
+ {price && (price.input || price.output) && (
+
+ {price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
+
+ )}
+
setPickerOpen(true)}
+ size="inline"
+ variant="text"
+ >
+ {t.onboarding.change}
+
+
+
+
+ }
+ loading={flow.saving}
+ onClick={onBegin}
+ />
+
+
+ {/*
+ 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.
+ */}
+
{
+ void setOnboardingModel(model)
+ setPickerOpen(false)
+ }}
+ open={pickerOpen}
+ />
+
+ )
+}
+
+export function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+
+export function Status({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/components/onboarding/glyph.tsx b/apps/desktop/src/components/onboarding/glyph.tsx
new file mode 100644
index 000000000..ec4c4cf0d
--- /dev/null
+++ b/apps/desktop/src/components/onboarding/glyph.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useState } from 'react'
+
+import { Loader2 } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+
+// Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
+// that decodes left-to-right from scrambled glyphs into the real text, with a
+// blinking block cursor. Ties onboarding's success moment to that same motif.
+// Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
+// Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
+const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
+const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
+// Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
+const SCRAMBLE_GLYPHS = [...'ππππ
πππππ²πΈπππππΉππ»ππ΄π
ππ½π¨π‘']
+const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
+const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
+// How many trailing characters of each word scramble during decode-in.
+const DECODE_TAIL = 4
+
+// Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
+// (resolved Latin chars stay full size) β keeps the easter-egg glyphs subtle.
+export function GlyphText({ text }: { text: string }) {
+ return (
+ <>
+ {Array.from(text, (ch, i) =>
+ GLYPH_SET.has(ch) ? (
+
+ {ch}
+
+ ) : (
+ ch
+ )
+ )}
+ >
+ )
+}
+
+function useDecoded(text: string): string {
+ const [out, setOut] = useState(text)
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
+ setOut(text)
+
+ return
+ }
+
+ // Each WORD keeps its head static and only churns its tail (last few chars),
+ // resolving left-to-right across all tails β same anchor-the-prefix trick the
+ // connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
+ // so both the provider and "CONNECTED" decode and time stays constant.
+ const chars = [...text]
+ const scrambleable = chars.map(() => false)
+
+ for (let i = 0; i < chars.length; ) {
+ if (!/[a-z0-9]/i.test(chars[i])) {
+ i += 1
+
+ continue
+ }
+
+ let j = i
+
+ while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
+ j += 1
+ }
+
+ for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
+ scrambleable[k] = true
+ }
+
+ i = j
+ }
+
+ const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
+ let resolved = 0
+
+ const id = window.setInterval(() => {
+ resolved += 0.5
+ const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
+
+ setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
+
+ if (Math.floor(resolved) >= tailIndices.length) {
+ window.clearInterval(id)
+ }
+ }, 45)
+
+ return () => window.clearInterval(id)
+ }, [text])
+
+ return out
+}
+
+// Continuously scrambles alphanumeric chars while `active` (used on exit so the
+// model name / button decay into ascii noise as they fade).
+export function useScramble(text: string, active: boolean): string {
+ const [out, setOut] = useState(text)
+
+ useEffect(() => {
+ if (!active) {
+ setOut(text)
+
+ return
+ }
+
+ const id = window.setInterval(() => {
+ setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
+ }, 45)
+
+ return () => window.clearInterval(id)
+ }, [text, active])
+
+ return out
+}
+
+export function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
+ const decoded = useDecoded(text.toUpperCase())
+
+ return (
+
+
+
+
+
+ )
+}
+
+// Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
+// mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
+// hover. The whole onboarding "you're in" moment leans into this motif.
+export function HackeryButton({
+ disabled,
+ label,
+ loading,
+ onClick
+}: {
+ disabled?: boolean
+ label: React.ReactNode
+ loading?: boolean
+ onClick: () => void
+}) {
+ return (
+
+ [
+ {loading ? : null}
+ {label}
+ ]
+
+ )
+}
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/onboarding/index.test.tsx
similarity index 98%
rename from apps/desktop/src/components/desktop-onboarding-overlay.test.tsx
rename to apps/desktop/src/components/onboarding/index.test.tsx
index 930280faf..35cac9c4d 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx
+++ b/apps/desktop/src/components/onboarding/index.test.tsx
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import type { OAuthProvider } from '@/types/hermes'
-import { Picker } from './desktop-onboarding-overlay'
+import { Picker } from '.'
function provider(id: string, name = id): OAuthProvider {
return {
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/onboarding/index.tsx
similarity index 52%
rename from apps/desktop/src/components/desktop-onboarding-overlay.tsx
rename to apps/desktop/src/components/onboarding/index.tsx
index b884e6f7a..fb61cb83e 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/onboarding/index.tsx
@@ -1,45 +1,37 @@
import { useStore } from '@nanostores/react'
-import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
-import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
-import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
-import { Loader } from '@/components/ui/loader'
-import { RowButton } from '@/components/ui/row-button'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
-import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
+import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
- cancelOnboardingFlow,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
- copyDeviceCode,
- copyExternalCommand,
DEFAULT_MANUAL_ONBOARDING_REASON,
DEFAULT_ONBOARDING_REASON,
dismissFirstRunOnboarding,
type OnboardingContext,
- type OnboardingFlow,
peekPendingProviderOAuth,
- recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
- setOnboardingCode,
setOnboardingMode,
- setOnboardingModel,
- startProviderOAuth,
- submitOnboardingCode
+ startProviderOAuth
} from '@/store/onboarding'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
+import { DocsLink, FlowPanel, Status } from './flow'
+import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers'
+
+export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers'
+
interface DesktopOnboardingOverlayProps {
enabled: boolean
onCompleted?: () => void
@@ -158,26 +150,6 @@ function useApiKeyCatalog(): ApiKeyOption[] {
}, [rows])
}
-const PROVIDER_DISPLAY: Record = {
- nous: { order: 0, title: 'Nous Portal' },
- 'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
- 'minimax-oauth': { order: 2, title: 'MiniMax' },
- 'qwen-oauth': { order: 3, title: 'Qwen Code' },
- 'xai-oauth': { order: 4, title: 'xAI Grok' },
- // Both Anthropic entries sit at the bottom: the API-key path first, then
- // the subscription OAuth path (only works with extra usage credits).
- anthropic: { order: 5, title: 'Anthropic API Key' },
- 'claude-code': { order: 6, title: 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' }
-}
-
-const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
-
-export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
-const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
-
-export const sortProviders = (providers: OAuthProvider[]) =>
- [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
-
// Exit choreography, mirroring the gateway "connecting" overlay's timing:
// text-out (360ms: CONNECTED fades down, rest scrambles+fades) β hold (300ms)
// β surface-out (520ms, held back by [transition-delay:660ms]). Finalize after.
@@ -519,100 +491,6 @@ function ChooseLaterLink() {
)
}
-export function FeaturedProviderRow({
- onSelect,
- provider
-}: {
- onSelect: (provider: OAuthProvider) => void
- provider: OAuthProvider
-}) {
- const { t } = useI18n()
- const loggedIn = provider.status?.logged_in
-
- return (
- onSelect(provider)}
- type="button"
- >
-
-
-
-
-
- {providerTitle(provider)}
-
- {loggedIn ? (
-
- ) : (
-
-
- {t.onboarding.recommended}
-
- )}
-
-
{t.onboarding.featuredPitch}
-
-
-
- )
-}
-
-function ConnectedTag() {
- const { t } = useI18n()
-
- return (
-
-
- {t.onboarding.connected}
-
- )
-}
-
-const PROVIDER_ROW_CLASS =
- 'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
-
-export function KeyProviderRow({ onClick }: { onClick: () => void }) {
- const { t } = useI18n()
-
- return (
-
-
-
OpenRouter
-
{t.onboarding.openRouterPitch}
-
-
-
- )
-}
-
-export function ProviderRow({
- onSelect,
- provider
-}: {
- onSelect: (provider: OAuthProvider) => void
- provider: OAuthProvider
-}) {
- const { t } = useI18n()
- const loggedIn = provider.status?.logged_in
- const Trail = provider.flow === 'external' ? Terminal : ChevronRight
-
- return (
- onSelect(provider)}>
-
-
-
- {providerTitle(provider)}
-
- {loggedIn ? : null}
-
-
{t.onboarding.flowSubtitles[provider.flow]}
-
-
-
- )
-}
-
// Presentational two-column key picker. Onboarding feeds it its curated
// options + a ctx-bound save; the Providers settings page feeds it the full
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
@@ -785,507 +663,3 @@ export function ApiKeyForm({
)
}
-
-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 {t.onboarding.startingSignIn(title)}
- }
-
- if (flow.status === 'submitting') {
- return {t.onboarding.verifyingCode(title)}
- }
-
- if (flow.status === 'success') {
- return
- }
-
- if (flow.status === 'confirming_model') {
- return
- }
-
- if (flow.status === 'error') {
- return (
-
-
-
- {flow.message || t.onboarding.signInFailed}
-
-
-
- {t.onboarding.pickDifferentProvider}
-
-
-
- )
- }
-
- if (flow.status === 'awaiting_user') {
- return (
-
-
- {t.onboarding.openedBrowser(title)}
- {t.onboarding.authorizeThere}
- {t.onboarding.copyAuthCode}
-
- setOnboardingCode(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
- placeholder={t.onboarding.pasteAuthCode}
- value={flow.code}
- />
- {t.onboarding.reopenAuthPage}}>
-
- void submitOnboardingCode(ctx)}>
- {t.common.continue}
-
-
-
- )
- }
-
- if (flow.status === 'awaiting_browser') {
- return (
-
- {t.onboarding.autoBrowser(title)}
- {t.onboarding.reopenSignInPage}}>
-
-
- {t.onboarding.waitingAuthorize}
-
-
-
-
- )
- }
-
- if (flow.status === 'external_pending') {
- return (
-
- {t.onboarding.externalPending(title)}
- void copyExternalCommand()} text={flow.provider.cli_command} />
- {t.onboarding.docs(title)}
- ) : null
- }
- >
-
- void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}
-
-
- )
- }
-
- if (flow.status !== 'polling') {
- return null
- }
-
- return (
-
- {t.onboarding.deviceCodeOpened(title)}
- void copyDeviceCode()} />
- {t.onboarding.reopenVerification}}>
-
-
- {t.onboarding.waitingAuthorize}
-
-
-
-
- )
-}
-
-function Step({ children, title }: { children: React.ReactNode; title: string }) {
- return (
-
-
{title}
- {children}
-
- )
-}
-
-// 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 (
-
- {[...code].map((ch, i) =>
- ch === '-' || ch === ' ' ? (
-
- β
-
- ) : (
-
- {ch}
-
- )
- )}
-
- )
-}
-
-function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
- const { t } = useI18n()
-
- return (
-
-
- $
- {text}
-
-
- {copied ? t.common.copied : t.onboarding.copy}
-
-
- )
-}
-
-function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) {
- return (
-
- )
-}
-
-function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
- const { t } = useI18n()
-
- return (
-
- {t.common.cancel}
-
- )
-}
-
-// Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
-// that decodes left-to-right from scrambled glyphs into the real text, with a
-// blinking block cursor. Ties onboarding's success moment to that same motif.
-// Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
-// Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
-const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
-const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
-// Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
-const SCRAMBLE_GLYPHS = [...'ππππ
πππππ²πΈπππππΉππ»ππ΄π
ππ½π¨π‘']
-const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
-const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
-// How many trailing characters of each word scramble during decode-in.
-const DECODE_TAIL = 4
-
-// Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
-// (resolved Latin chars stay full size) β keeps the easter-egg glyphs subtle.
-function GlyphText({ text }: { text: string }) {
- return (
- <>
- {Array.from(text, (ch, i) =>
- GLYPH_SET.has(ch) ? (
-
- {ch}
-
- ) : (
- ch
- )
- )}
- >
- )
-}
-
-function useDecoded(text: string): string {
- const [out, setOut] = useState(text)
-
- useEffect(() => {
- if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
- setOut(text)
-
- return
- }
-
- // Each WORD keeps its head static and only churns its tail (last few chars),
- // resolving left-to-right across all tails β same anchor-the-prefix trick the
- // connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
- // so both the provider and "CONNECTED" decode and time stays constant.
- const chars = [...text]
- const scrambleable = chars.map(() => false)
-
- for (let i = 0; i < chars.length; ) {
- if (!/[a-z0-9]/i.test(chars[i])) {
- i += 1
-
- continue
- }
-
- let j = i
-
- while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
- j += 1
- }
-
- for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
- scrambleable[k] = true
- }
-
- i = j
- }
-
- const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
- let resolved = 0
-
- const id = window.setInterval(() => {
- resolved += 0.5
- const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
-
- setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
-
- if (Math.floor(resolved) >= tailIndices.length) {
- window.clearInterval(id)
- }
- }, 45)
-
- return () => window.clearInterval(id)
- }, [text])
-
- return out
-}
-
-// Continuously scrambles alphanumeric chars while `active` (used on exit so the
-// model name / button decay into ascii noise as they fade).
-function useScramble(text: string, active: boolean): string {
- const [out, setOut] = useState(text)
-
- useEffect(() => {
- if (!active) {
- setOut(text)
-
- return
- }
-
- const id = window.setInterval(() => {
- setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
- }, 45)
-
- return () => window.clearInterval(id)
- }, [text, active])
-
- return out
-}
-
-function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
- const decoded = useDecoded(text.toUpperCase())
-
- return (
-
-
-
-
-
- )
-}
-
-// Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
-// mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
-// hover. The whole onboarding "you're in" moment leans into this motif.
-function HackeryButton({
- disabled,
- label,
- loading,
- onClick
-}: {
- disabled?: boolean
- label: React.ReactNode
- loading?: boolean
- onClick: () => void
-}) {
- return (
-
- [
- {loading ? : null}
- {label}
- ]
-
- )
-}
-
-function ConfirmingModelPanel({
- flow,
- leaving,
- onBegin
-}: {
- flow: Extract
- 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 (
-
-
-
-
-
-
- {t.onboarding.defaultModel}
-
- {freeTier === true && (
-
- {t.onboarding.freeTier}
-
- )}
- {freeTier === false && (
-
- {t.onboarding.pro}
-
- )}
-
-
-
-
- {price && (price.input || price.output) && (
-
- {price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
-
- )}
-
setPickerOpen(true)}
- size="inline"
- variant="text"
- >
- {t.onboarding.change}
-
-
-
-
- }
- loading={flow.saving}
- onClick={onBegin}
- />
-
-
- {/*
- 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.
- */}
-
{
- void setOnboardingModel(model)
- setPickerOpen(false)
- }}
- open={pickerOpen}
- />
-
- )
-}
-
-function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
- return (
-
-
-
- {children}
-
-
- )
-}
-
-function Status({ children }: { children: React.ReactNode }) {
- return (
-
-
- {children}
-
- )
-}
diff --git a/apps/desktop/src/components/onboarding/providers.tsx b/apps/desktop/src/components/onboarding/providers.tsx
new file mode 100644
index 000000000..f7dafdae9
--- /dev/null
+++ b/apps/desktop/src/components/onboarding/providers.tsx
@@ -0,0 +1,118 @@
+import { RowButton } from '@/components/ui/row-button'
+import { useI18n } from '@/i18n'
+import { Check, ChevronRight, Terminal } from '@/lib/icons'
+import type { OAuthProvider } from '@/types/hermes'
+
+const PROVIDER_DISPLAY: Record = {
+ nous: { order: 0, title: 'Nous Portal' },
+ 'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
+ 'minimax-oauth': { order: 2, title: 'MiniMax' },
+ 'qwen-oauth': { order: 3, title: 'Qwen Code' },
+ 'xai-oauth': { order: 4, title: 'xAI Grok' },
+ // Both Anthropic entries sit at the bottom: the API-key path first, then
+ // the subscription OAuth path (only works with extra usage credits).
+ anthropic: { order: 5, title: 'Anthropic API Key' },
+ 'claude-code': { order: 6, title: 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' }
+}
+
+const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
+
+export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
+const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
+
+export const sortProviders = (providers: OAuthProvider[]) =>
+ [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
+
+export function FeaturedProviderRow({
+ onSelect,
+ provider
+}: {
+ onSelect: (provider: OAuthProvider) => void
+ provider: OAuthProvider
+}) {
+ const { t } = useI18n()
+ const loggedIn = provider.status?.logged_in
+
+ return (
+ onSelect(provider)}
+ type="button"
+ >
+
+
+
+
+
+ {providerTitle(provider)}
+
+ {loggedIn ? (
+
+ ) : (
+
+
+ {t.onboarding.recommended}
+
+ )}
+
+
{t.onboarding.featuredPitch}
+
+
+
+ )
+}
+
+function ConnectedTag() {
+ const { t } = useI18n()
+
+ return (
+
+
+ {t.onboarding.connected}
+
+ )
+}
+
+const PROVIDER_ROW_CLASS =
+ 'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
+
+export function KeyProviderRow({ onClick }: { onClick: () => void }) {
+ const { t } = useI18n()
+
+ return (
+
+
+
OpenRouter
+
{t.onboarding.openRouterPitch}
+
+
+
+ )
+}
+
+export function ProviderRow({
+ onSelect,
+ provider
+}: {
+ onSelect: (provider: OAuthProvider) => void
+ provider: OAuthProvider
+}) {
+ const { t } = useI18n()
+ const loggedIn = provider.status?.logged_in
+ const Trail = provider.flow === 'external' ? Terminal : ChevronRight
+
+ return (
+ onSelect(provider)}>
+
+
+
+ {providerTitle(provider)}
+
+ {loggedIn ? : null}
+
+
{t.onboarding.flowSubtitles[provider.flow]}
+
+
+
+ )
+}