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:
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,6 +30,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
...overrides
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user