fix(desktop): gate prompts on provider setup

Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors.
This commit is contained in:
Brooklyn Nicholson 2026-05-07 22:41:10 -04:00
parent 89d5ee4b10
commit 8d95e006b8
4 changed files with 76 additions and 52 deletions

View file

@ -405,7 +405,6 @@ export function DesktopController() {
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onOpenSettings={openSettings}
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />

View file

@ -25,6 +25,7 @@ import {
type ComposerAttachment
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
@ -45,6 +46,16 @@ function blobToDataUrl(blob: Blob): Promise<string> {
})
}
interface SetupStatus {
provider_configured?: boolean
}
function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY/i.test(message)
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@ -216,6 +227,15 @@ export function usePromptActions({
setAwaitingResponse(true)
clearNotifications()
const setup = await requestGateway<SetupStatus>('setup.status').catch(() => null)
if (setup?.provider_configured === false) {
releaseBusy()
requestDesktopOnboarding('Add a provider credential before sending your first message.')
return
}
let sessionId = activeSessionId
if (!sessionId) {
@ -257,6 +277,13 @@ export function usePromptActions({
} catch (err) {
releaseBusy()
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
if (isProviderSetupError(err)) {
requestDesktopOnboarding('Add a provider credential before sending your first message.')
return
}
notifyError(err, 'Prompt failed')
}
},

View file

@ -1,17 +1,18 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getEnvVars, setEnvVar } from '@/hermes'
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2, Settings2, X } from '@/lib/icons'
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, completeDesktopOnboarding } from '@/store/onboarding'
import type { EnvVarInfo } from '@/types/hermes'
interface DesktopOnboardingOverlayProps {
enabled: boolean
onCompleted?: () => void
onOpenSettings?: () => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@ -25,8 +26,6 @@ interface ProviderOption {
helper: string
}
const DISMISS_KEY = 'desktop.onboarding.dismissed_until_reload'
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
{
key: 'OPENROUTER_API_KEY',
@ -60,22 +59,6 @@ const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
}
]
function isDismissedForSession() {
try {
return window.sessionStorage.getItem(DISMISS_KEY) === '1'
} catch {
return false
}
}
function dismissForSession() {
try {
window.sessionStorage.setItem(DISMISS_KEY, '1')
} catch {
// Ignore storage failures; in-memory state still dismisses the overlay.
}
}
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
return info?.description ? `${option.label} (${option.key})` : option.label
}
@ -83,20 +66,20 @@ function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
export function DesktopOnboardingOverlay({
enabled,
onCompleted,
onOpenSettings,
requestGateway
}: DesktopOnboardingOverlayProps) {
const onboarding = useStore($desktopOnboarding)
const [checking, setChecking] = useState(false)
const [dismissed, setDismissed] = useState(isDismissedForSession)
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [error, setError] = useState<string | null>(null)
const [providerConfigured, setProviderConfigured] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key)
const [value, setValue] = useState('')
const shouldCheck = enabled || onboarding.requested
useEffect(() => {
if (!enabled || dismissed) {
if (!shouldCheck) {
return
}
@ -116,6 +99,10 @@ export function DesktopOnboardingOverlay({
setProviderConfigured(Boolean(status.provider_configured))
setEnvVars(vars)
if (status.provider_configured) {
completeDesktopOnboarding()
}
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
if (firstAvailable) {
@ -136,7 +123,7 @@ export function DesktopOnboardingOverlay({
void checkSetup()
return () => void (cancelled = true)
}, [dismissed, enabled, requestGateway])
}, [requestGateway, shouldCheck])
const providerOptions = useMemo(
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
@ -169,6 +156,7 @@ export function DesktopOnboardingOverlay({
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
setProviderConfigured(true)
setValue('')
completeDesktopOnboarding()
onCompleted?.()
} catch (err) {
notifyError(err, `Failed to save ${selectedKey}`)
@ -178,17 +166,7 @@ export function DesktopOnboardingOverlay({
}
}
function handleDismiss() {
dismissForSession()
setDismissed(true)
}
function handleOpenSettings() {
handleDismiss()
onOpenSettings?.()
}
if (!enabled || dismissed || providerConfigured) {
if (!shouldCheck || providerConfigured) {
return null
}
@ -209,9 +187,6 @@ export function DesktopOnboardingOverlay({
</p>
</div>
</div>
<Button onClick={handleDismiss} size="icon-sm" title="Configure later" variant="ghost">
<X className="size-4" />
</Button>
</div>
</div>
@ -223,6 +198,13 @@ export function DesktopOnboardingOverlay({
</div>
) : null}
{onboarding.reason ? (
<div className="flex gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{onboarding.reason}</span>
</div>
) : null}
<div className="grid gap-2">
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Provider</label>
<div className="grid gap-2 sm:grid-cols-2">
@ -287,20 +269,11 @@ export function DesktopOnboardingOverlay({
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
<Button onClick={handleOpenSettings} variant="outline">
<Settings2 className="size-4" />
Open full settings
<div className="flex justify-end border-t border-border pt-5">
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Saving' : 'Save and continue'}
</Button>
<div className="flex gap-2">
<Button onClick={handleDismiss} variant="ghost">
Configure later
</Button>
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Saving' : 'Save and continue'}
</Button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
import { atom } from 'nanostores'
interface DesktopOnboardingState {
reason: null | string
requested: boolean
}
export const $desktopOnboarding = atom<DesktopOnboardingState>({
reason: null,
requested: false
})
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
$desktopOnboarding.set({
reason,
requested: true
})
}
export function completeDesktopOnboarding() {
$desktopOnboarding.set({
reason: null,
requested: false
})
}