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} +
+
+ +
+
+ ) + } + + if (flow.status === 'awaiting_user') { + return ( + +
    +
  1. {t.onboarding.openedBrowser(title)}
  2. +
  3. {t.onboarding.authorizeThere}
  4. +
  5. {t.onboarding.copyAuthCode}
  6. +
+ setOnboardingCode(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} + placeholder={t.onboarding.pasteAuthCode} + value={flow.code} + /> + {t.onboarding.reopenAuthPage}}> + + + +
+ ) + } + + 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 + } + > + + + +
+ ) + } + + 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 ( + + ) +} + +function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) { + const { t } = useI18n() + + return ( +
+ + $ + {text} + + +
+ ) +} + +function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) { + return ( +
+
{left}
+
{children}
+
+ ) +} + +function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { + const { t } = useI18n() + + return ( + + ) +} + +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 || '?')} +

+ )} + +
+ +
+ } + 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 ( + + ) +} + +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 ( + + ) +} 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 ( - - ) -} - -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} -
-
- -
-
- ) - } - - if (flow.status === 'awaiting_user') { - return ( - -
    -
  1. {t.onboarding.openedBrowser(title)}
  2. -
  3. {t.onboarding.authorizeThere}
  4. -
  5. {t.onboarding.copyAuthCode}
  6. -
- setOnboardingCode(e.target.value)} - onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} - placeholder={t.onboarding.pasteAuthCode} - value={flow.code} - /> - {t.onboarding.reopenAuthPage}}> - - - -
- ) - } - - 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 - } - > - - - -
- ) - } - - 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 ( - - ) -} - -function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) { - const { t } = useI18n() - - return ( -
- - $ - {text} - - -
- ) -} - -function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) { - return ( -
-
{left}
-
{children}
-
- ) -} - -function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { - const { t } = useI18n() - - return ( - - ) -} - -// 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 ( - - ) -} - -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 || '?')} -

- )} - -
- -
- } - 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 ( - - ) -} - -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 ( + + ) +} + +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]}

+
+ +
+ ) +}