diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx index 25d456326..38084ae4c 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -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() + + 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() + + expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull() + }) }) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 247f5cc6e..08f4da29d 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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 ( - setOnboardingMode('oauth')} - onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)} - /> +
+ setOnboardingMode('oauth')} + onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)} + /> + {manual ? null : ( +
+ +
+ )} +
) } @@ -352,7 +367,11 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { ) : null} -
+
+ {/* 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 ? : } + ) +} + export function FeaturedProviderRow({ onSelect, provider diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 221d2ee3e..b6bb51194 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -30,6 +30,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo providers: null, reason: null, requested: false, + firstRunSkipped: false, manual: false, ...overrides } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 341f389c5..7b5202986 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -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 }) }