Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/desktop/src/components/desktop-onboarding-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function setProviders(providers: OAuthProvider[]) {
providers,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
} satisfies DesktopOnboardingState)
}
Expand All @@ -33,13 +34,21 @@ 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' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
})
Expand Down Expand Up @@ -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()
})
})
48 changes: 41 additions & 7 deletions apps/desktop/src/components/desktop-onboarding-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
dismissFirstRunOnboarding,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
)
}

Expand Down Expand Up @@ -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')}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/store/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
...overrides
}
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.'
Expand Down Expand Up @@ -105,13 +113,42 @@ 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' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
}

Expand Down Expand Up @@ -398,17 +435,33 @@ 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' },
mode: 'oauth',
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 })
}
Expand Down
Loading