fix(desktop): mount onboarding from frame 1 to kill the FOUT

Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount.

The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell.
This commit is contained in:
Brooklyn Nicholson 2026-05-08 07:54:53 -04:00
parent 11d04d9d5e
commit 2d0aa1b7cb
3 changed files with 58 additions and 5 deletions

View file

@ -3,9 +3,18 @@ import { useStore } from '@nanostores/react'
import { Loader } from '@/components/ui/loader'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
export function DesktopBootOverlay() {
const boot = useStore($desktopBoot)
const onboarding = useStore($desktopOnboarding)
// Onboarding overlay covers the whole "first run" UX: it mounts from frame 1
// and renders boot progress inline. Yield to it whenever it has not yet
// confirmed the user is configured.
if (onboarding.configured !== true) {
return null
}
if (!boot.visible) {
return null

View file

@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Check, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
@ -107,7 +108,7 @@ const sortProviders = (providers: OAuthProvider[]) =>
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const onboarding = useStore($desktopOnboarding)
const visible = (enabled || onboarding.requested) && !onboarding.configured
const boot = useStore($desktopBoot)
const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted })
ctxRef.current = { requestGateway, onCompleted }
@ -125,23 +126,64 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
if (!visible) {
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
if (onboarding.configured === true) {
return null
}
const { flow } = onboarding
const ready = enabled && onboarding.configured === false
const showPicker = flow.status === 'idle' || flow.status === 'success'
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<Header />
<div className="grid gap-5 p-6">{showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} />}</div>
<div className="grid gap-5 p-6">
{ready ? (
showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} />
)
) : (
<Preparing boot={boot} />
)}
</div>
</div>
</div>
)
}
function Preparing({ boot }: { boot: DesktopBootState }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
return (
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
While we get you set up Hermes is finishing install. This usually takes under a minute on first run.
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
hasError && 'bg-destructive'
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>
</div>
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
</div>
)
}
function Header() {
return (
<div className="border-b border-border bg-muted/30 px-6 py-5">

View file

@ -27,7 +27,9 @@ export type OnboardingFlow =
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
export interface DesktopOnboardingState {
configured: boolean
/** null until the first runtime check resolves; lets the overlay render
* during boot without flickering for already-configured users. */
configured: boolean | null
flow: OnboardingFlow
mode: OnboardingMode
providers: null | OAuthProvider[]
@ -41,7 +43,7 @@ export interface OnboardingContext {
}
const INITIAL: DesktopOnboardingState = {
configured: true,
configured: null,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,