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.
This commit is contained in:
parent
f99ba56df4
commit
18d54bf0fd
7 changed files with 662 additions and 636 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
364
apps/desktop/src/components/onboarding/flow.tsx
Normal file
364
apps/desktop/src/components/onboarding/flow.tsx
Normal file
|
|
@ -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 <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>
|
||||
)
|
||||
}
|
||||
170
apps/desktop/src/components/onboarding/glyph.tsx
Normal file
170
apps/desktop/src/components/onboarding/glyph.tsx
Normal file
|
|
@ -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) ? (
|
||||
<span className="text-[0.62em]" key={i}>
|
||||
{ch}
|
||||
</span>
|
||||
) : (
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
<GlyphText text={decoded} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
|
||||
style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
<style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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<string, { order: number; title: string }> = {
|
||||
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 (
|
||||
<button
|
||||
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="arc-border arc-reverse arc-nous" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? (
|
||||
<ConnectedTag />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
|
||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
|
||||
{t.onboarding.recommended}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.featuredPitch}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedTag() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.onboarding.connected}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<RowButton className={PROVIDER_ROW_CLASS} onClick={onClick}>
|
||||
<div className="min-w-0">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</RowButton>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<RowButton className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
</div>
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</RowButton>
|
||||
)
|
||||
}
|
||||
|
||||
// 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({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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) ? (
|
||||
<span className="text-[0.62em]" key={i}>
|
||||
{ch}
|
||||
</span>
|
||||
) : (
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
<GlyphText text={decoded} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
|
||||
style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
<style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
118
apps/desktop/src/components/onboarding/providers.tsx
Normal file
118
apps/desktop/src/components/onboarding/providers.tsx
Normal file
|
|
@ -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<string, { order: number; title: string }> = {
|
||||
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 (
|
||||
<button
|
||||
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="arc-border arc-reverse arc-nous" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? (
|
||||
<ConnectedTag />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
|
||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
|
||||
{t.onboarding.recommended}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.featuredPitch}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedTag() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.onboarding.connected}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<RowButton className={PROVIDER_ROW_CLASS} onClick={onClick}>
|
||||
<div className="min-w-0">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</RowButton>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<RowButton className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
</div>
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</RowButton>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue