feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483)

The first-run provider picker was a hard gate — the only way out was
connecting a provider. Add an 'I'll choose a provider later' link that
dismisses the overlay and persists the skip to localStorage so it never
re-nags on subsequent launches. Users connect a provider any time from
Settings -> Providers (manual onboarding already bypasses the skip gate).

- onboarding.ts: firstRunSkipped state seeded from localStorage
  (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action;
  completeDesktopOnboarding clears the flag once a provider connects.
- overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink
  rendered in both the OAuth picker footer and the API-key fallback, first-run only.
- tests: skip persists + hidden in manual mode; full-state fixtures updated.
This commit is contained in:
Teknium
2026-06-04 19:40:54 -07:00
committed by GitHub
parent 5bcb63e400
commit 9cc47b20cb
4 changed files with 124 additions and 7 deletions

View File

@ -25,6 +25,7 @@ function setProviders(providers: OAuthProvider[]) {
providers,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
} satisfies DesktopOnboardingState)
}
@ -33,6 +34,13 @@ const ctx: OnboardingContext = { requestGateway: async () => undefined as never
afterEach(() => {
cleanup()
try {
window.localStorage.clear()
} catch {
// jsdom localStorage should always be present; ignore if not.
}
$desktopOnboarding.set({
configured: null,
flow: { status: 'idle' },
@ -40,6 +48,7 @@ afterEach(() => {
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
})
@ -68,4 +77,24 @@ describe('onboarding Picker', () => {
expect(screen.queryByText('Other sign-in options')).toBeNull()
expect(screen.queryByText('Recommended')).toBeNull()
})
it('offers "choose later" on first run and persists the skip', () => {
setProviders([provider('nous', 'Nous Portal')])
render(<Picker ctx={ctx} />)
const skip = screen.getByRole('button', { name: "I'll choose a provider later" })
fireEvent.click(skip)
expect($desktopOnboarding.get().firstRunSkipped).toBe(true)
expect(window.localStorage.getItem('hermes-onboarding-skipped-v1')).toBe('1')
})
it('hides "choose later" in manual (add-provider) mode', () => {
setProviders([provider('nous', 'Nous Portal')])
$desktopOnboarding.set({ ...$desktopOnboarding.get(), manual: true })
render(<Picker ctx={ctx} />)
expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull()
})
})

View File

@ -29,6 +29,7 @@ import {
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
dismissFirstRunOnboarding,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
@ -189,6 +190,13 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return null
}
// The user chose "I'll choose a provider later" on first run. Stay out of the
// way on every subsequent launch — they re-enter via Settings → Providers
// (manual mode), which sets manual=true and bypasses this gate.
if (onboarding.firstRunSkipped && !onboarding.manual) {
return null
}
const { flow } = onboarding
const rawReason = onboarding.reason?.trim() || null
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
@ -304,18 +312,25 @@ const persistShowAll = (value: boolean) => {
}
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { mode, providers } = useStore($desktopOnboarding)
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
{manual ? null : (
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
<ChooseLaterLink />
</div>
)}
</div>
)
}
@ -352,7 +367,11 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</button>
) : null}
<div className="flex justify-end pt-1">
<div className="flex items-center justify-between gap-3 pt-1">
{/* First run only: let the user defer the choice and land in the app.
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('apikey')}
@ -365,6 +384,21 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
// "I'll choose a provider later" — dismisses the first-run picker and persists
// the skip so it never re-nags. The user connects a provider any time from
// Settings → Providers. Rendered only on the unconfigured first-run flow.
function ChooseLaterLink() {
return (
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => dismissFirstRunOnboarding()}
type="button"
>
I'll choose a provider later
</button>
)
}
export function FeaturedProviderRow({
onSelect,
provider

View File

@ -30,6 +30,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
...overrides
}

View File

@ -60,6 +60,13 @@ export interface DesktopOnboardingState {
providers: null | OAuthProvider[]
reason: null | string
requested: boolean
/** True when the user explicitly chose "I'll choose a provider later" on the
* first-run picker. Persisted to localStorage so the blocking overlay never
* re-nags on subsequent launches — the user can connect a provider any time
* from Settings → Providers (or the model picker's "Add provider"). Distinct
* from `configured`: the app still has no usable provider, so chat won't work
* until one is connected; we just stop forcing the choice up front. */
firstRunSkipped: boolean
/** True when the user explicitly opened the provider selector to add /
* switch providers from an already-configured app (e.g. via the model
* picker's "Add provider" button). Forces the overlay to show the picker
@ -73,6 +80,7 @@ export interface OnboardingContext {
}
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const SKIP_CACHE_KEY = 'hermes-onboarding-skipped-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
@ -105,6 +113,34 @@ function writeCachedConfigured(value: boolean) {
}
}
function readCachedSkipped(): boolean {
if (typeof window === 'undefined') {
return false
}
try {
return window.localStorage.getItem(SKIP_CACHE_KEY) === '1'
} catch {
return false
}
}
function writeCachedSkipped(value: boolean) {
if (typeof window === 'undefined') {
return
}
try {
if (value) {
window.localStorage.setItem(SKIP_CACHE_KEY, '1')
} else {
window.localStorage.removeItem(SKIP_CACHE_KEY)
}
} catch {
// localStorage unavailable — degrade silently.
}
}
const INITIAL: DesktopOnboardingState = {
configured: readCachedConfigured(),
flow: { status: 'idle' },
@ -112,6 +148,7 @@ const INITIAL: DesktopOnboardingState = {
providers: null,
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
}
@ -398,6 +435,9 @@ export function closeManualOnboarding() {
export function completeDesktopOnboarding() {
clearPoll()
writeCachedConfigured(true)
// A real provider is now connected, so any earlier "choose later" skip is
// moot — clear it so the flag never lingers in a configured install.
writeCachedSkipped(false)
$desktopOnboarding.set({
configured: true,
flow: { status: 'idle' },
@ -405,10 +445,23 @@ export function completeDesktopOnboarding() {
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
}
// "I'll choose a provider later" on the first-run picker. Persists the skip so
// the blocking overlay never re-nags on future launches, and dismisses it now
// so the user lands in the app. Chat won't work until a provider is connected
// (from Settings → Providers or the model picker's "Add provider") — this only
// stops forcing the choice up front. Distinct from completeDesktopOnboarding,
// which marks the app actually configured.
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
patch({ mode })
}