diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 706c84f7061..495aad30372 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -405,7 +405,6 @@ export function DesktopController() { void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} - onOpenSettings={openSettings} requestGateway={requestGateway} /> diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 410f1dcf219..1ba8c65d069 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -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 { }) } +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 @@ -216,6 +227,15 @@ export function usePromptActions({ setAwaitingResponse(true) clearNotifications() + const setup = await requestGateway('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') } }, diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 893dad55031..55f05cfbf9b 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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: (method: string, params?: Record) => Promise } @@ -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 | null>(null) const [error, setError] = useState(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({

- @@ -223,6 +198,13 @@ export function DesktopOnboardingOverlay({ ) : null} + {onboarding.reason ? ( +
+ + {onboarding.reason} +
+ ) : null} +
@@ -287,20 +269,11 @@ export function DesktopOnboardingOverlay({
) : null} -
- -
- - -
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts new file mode 100644 index 00000000000..b291ec1f218 --- /dev/null +++ b/apps/desktop/src/store/onboarding.ts @@ -0,0 +1,25 @@ +import { atom } from 'nanostores' + +interface DesktopOnboardingState { + reason: null | string + requested: boolean +} + +export const $desktopOnboarding = atom({ + 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 + }) +}