mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
feat(desktop): add model-confirmation step to onboarding
After OAuth/API-key login completes, onboarding now shows a confirmation
card with the curated default model and a Change button before dropping
the user into chat. Closes the gap where the desktop's `model.default`
was empty after first launch and the agent had to fall back to whatever
heuristic happened to fire — leaving users wondering "why am I getting
sonnet-4 when I logged into Nous Portal?"
Why
- Desktop onboarding only persisted credentials, never `model.default`.
The CLI's `hermes model` command pairs provider + model selection,
but the desktop's onboarding skipped the model step entirely.
- Result: users saw whichever model the agent's auto-fallback picked,
unpredictably and undocumented.
- For the BUILD demo we want users to land on the model they expect
for their provider, with a clear "this is what you're getting" UI
and a one-click path to change it before chatting.
How
- New `confirming_model` flow status carries the just-authenticated
provider slug, current default model, label, and a saving flag.
- `completeWithModelConfirm()` runs after credentials succeed: reloads
env, verifies runtime, fetches /api/model/options to find the curated
first-model for the provider, persists it via /api/model/set, then
transitions into `confirming_model`.
- If anything fails (no providers returned, network error), falls
through to the previous behaviour — onboarding completes without
the confirm step. Polish, not a hard requirement.
- All four credential paths (device_code OAuth, PKCE OAuth, external
CLI flow, API key) now use completeWithModelConfirm instead of
reloadAndConnect.
UI
- `ConfirmingModelPanel` shows: green "<provider> connected" banner,
card with "Default model: <name>" + Change button, and a "Start
chatting" CTA that finalises onboarding.
- Reuses the existing `ModelPickerDialog` (the same picker available
from the chat shell) for the change-model UX. Search, filtering,
multi-provider listing — all already built.
- Stacking: ModelPickerDialog defaults to z-130, which renders UNDER
the onboarding overlay (z-1300) and breaks pointer events. Added
optional `contentClassName` prop to ModelPickerDialog so callers
can override; onboarding passes `z-[1310]`.
Provider-slug matching
- For OAuth flows: pass `provider.id` directly as the preferred slug.
- For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key
prefix strip. Also includes the user-visible label as a fallback
candidate.
- fetchProviderDefaultModel falls back to the first authenticated
provider in the response if no preferred slug matches — so even a
miss still surfaces a reasonable default.
Files
- apps/desktop/src/store/onboarding.ts:
+ new `confirming_model` flow variant
+ fetchProviderDefaultModel + completeWithModelConfirm helpers
+ setOnboardingModel (optimistic update + revert on failure)
+ confirmOnboardingModel (finalises onboarding from the card)
- reloadAndConnect (replaced; the four call sites now go through
completeWithModelConfirm)
- apps/desktop/src/components/desktop-onboarding-overlay.tsx:
+ ConfirmingModelPanel component
+ new branch in FlowPanel for status `confirming_model`
+ ModelPickerDialog usage with z-[1310] content class
- apps/desktop/src/components/model-picker.tsx:
+ optional `contentClassName` prop on ModelPickerDialog so the
dialog can be stacked on top of other fixed overlays
Tested
- `npm run type-check` passes
- `npx eslint` clean on touched files
- Live test in `npm run dev`: cleared onboarding cache, walked
through Nous device-code flow, saw confirm card with curated
default, clicked Change → ModelPickerDialog rendered above the
onboarding overlay with working pointer events, picked a different
model, "Start chatting" persisted to ~/.hermes/config.yaml.
This commit is contained in:
parent
32f0fde35c
commit
2252160dcf
3 changed files with 256 additions and 13 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Check, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
|
||||
|
|
@ -9,6 +10,7 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
|||
import {
|
||||
$desktopOnboarding,
|
||||
cancelOnboardingFlow,
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
type OnboardingContext,
|
||||
|
|
@ -18,6 +20,7 @@ import {
|
|||
saveOnboardingApiKey,
|
||||
setOnboardingCode,
|
||||
setOnboardingMode,
|
||||
setOnboardingModel,
|
||||
startProviderOAuth,
|
||||
submitOnboardingCode
|
||||
} from '@/store/onboarding'
|
||||
|
|
@ -394,11 +397,15 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||
return (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
<Check className="size-4" />
|
||||
{title} connected. You're ready to chat.
|
||||
{title} connected. Picking a default model...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'confirming_model') {
|
||||
return <ConfirmingModelPanel ctx={ctx} flow={flow} />
|
||||
}
|
||||
|
||||
if (flow.status === 'error') {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
|
|
@ -526,6 +533,69 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConfirmingModelPanel({
|
||||
ctx,
|
||||
flow
|
||||
}: {
|
||||
ctx: OnboardingContext
|
||||
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
|
||||
}) {
|
||||
// Local state controls whether the model picker dialog is open.
|
||||
// We reuse the existing ModelPickerDialog component (the same picker
|
||||
// available from the chat shell) rather than building an inline
|
||||
// dropdown — gives us search, multi-provider listing if relevant, and
|
||||
// a familiar UI for users who'll see this picker again later.
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
<Check className="size-4 shrink-0" />
|
||||
<span>{flow.label} connected.</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
||||
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
||||
</div>
|
||||
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
|
||||
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
Start chatting
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
ModelPickerDialog defaults to z-130 on its content, which renders
|
||||
UNDER the onboarding overlay (z-1300) and breaks pointer events.
|
||||
Bump it above with z-[1310] so the picker sits on top of the
|
||||
onboarding panel. The dialog's own dim-backdrop layer stays at
|
||||
its default z-120 — the onboarding overlay is already dimming
|
||||
the rest of the screen, so we don't want a second backdrop.
|
||||
*/}
|
||||
<ModelPickerDialog
|
||||
contentClassName="z-[1310]"
|
||||
currentModel={flow.currentModel}
|
||||
currentProvider={flow.providerSlug}
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={({ model }) => {
|
||||
void setOnboardingModel(model)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
open={pickerOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
|
||||
return (
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ interface ModelPickerDialogProps {
|
|||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
||||
/**
|
||||
* Optional class to apply to DialogContent. Use to override z-index when
|
||||
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
||||
* onboarding overlay, which sits at z-1300; the default Dialog z-130 ends
|
||||
* up rendering underneath and blocks pointer events).
|
||||
*/
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function ModelPickerDialog({
|
||||
|
|
@ -31,7 +38,8 @@ export function ModelPickerDialog({
|
|||
sessionId,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
onSelect
|
||||
onSelect,
|
||||
contentClassName
|
||||
}: ModelPickerDialogProps) {
|
||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||
|
||||
|
|
@ -71,7 +79,7 @@ export function ModelPickerDialog({
|
|||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0">
|
||||
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
|
||||
<DialogHeader className="border-b border-border px-4 py-3">
|
||||
<DialogTitle>Switch model</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs leading-relaxed">
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ import { atom } from 'nanostores'
|
|||
|
||||
import {
|
||||
cancelOAuthSession,
|
||||
getGlobalModelOptions,
|
||||
listOAuthProviders,
|
||||
pollOAuthSession,
|
||||
setEnvVar,
|
||||
setModelAssignment,
|
||||
startOAuthLogin,
|
||||
submitOAuthCode
|
||||
} from '@/hermes'
|
||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
||||
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
||||
|
||||
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
|
||||
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
|
||||
|
|
@ -25,6 +27,20 @@ export type OnboardingFlow =
|
|||
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
|
||||
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
|
||||
| { provider: OAuthProvider; status: 'success' }
|
||||
| {
|
||||
// After successful credential acquisition, before completing
|
||||
// onboarding: show the user which model they're getting and let
|
||||
// them change it. providerSlug is the model.options slug for the
|
||||
// just-authenticated provider (used to persist the chosen model
|
||||
// via /api/model/set). The change-model UI uses the existing
|
||||
// ModelPickerDialog, which fetches its own model list from
|
||||
// /api/model/options — no need to cache the list here.
|
||||
currentModel: string
|
||||
label: string
|
||||
providerSlug: string
|
||||
saving: boolean
|
||||
status: 'confirming_model'
|
||||
}
|
||||
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
|
||||
|
||||
export interface DesktopOnboardingState {
|
||||
|
|
@ -118,17 +134,109 @@ function notifyReady(provider: string) {
|
|||
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
|
||||
}
|
||||
|
||||
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: (reason: null | string) => void) {
|
||||
// After credentials are persisted, ask the backend which provider+models
|
||||
// are now authenticated. Pick the first curated model for the matching
|
||||
// provider as a sensible default, persist it via /api/model/set, and
|
||||
// transition to the model-confirmation step. If anything goes wrong
|
||||
// fetching options (no providers returned, network error), the caller
|
||||
// falls through to completing onboarding without showing the confirm
|
||||
// card — the user gets the undefined-model auto-selection behaviour
|
||||
// we had before, which works but is surprising. The confirm step is
|
||||
// opportunistic polish, not a hard requirement for onboarding.
|
||||
async function fetchProviderDefaultModel(
|
||||
preferredSlugs: string[]
|
||||
): Promise<null | { providerSlug: string; defaultModel: string }> {
|
||||
let options
|
||||
|
||||
try {
|
||||
options = await getGlobalModelOptions()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const providers = options?.providers ?? []
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try each preferred slug (lowercased), fall back to the first provider
|
||||
// returned (model.options orders by recency / authenticated state, so
|
||||
// the just-authenticated provider is usually first anyway).
|
||||
const lower = preferredSlugs.map(s => s.toLowerCase())
|
||||
const matched = providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
|
||||
|
||||
const models = matched.models ?? []
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
providerSlug: String(matched.slug),
|
||||
defaultModel: String(models[0])
|
||||
}
|
||||
}
|
||||
|
||||
// After OAuth/API-key success: reload the backend env, verify runtime,
|
||||
// then either show the model-confirm step or fall straight through to
|
||||
// completion if we can't determine a default.
|
||||
//
|
||||
// onFail receives the runtime-readiness `reason` from checkRuntime so
|
||||
// the caller can fold it into a user-facing error — same contract as
|
||||
// reloadAndConnect used to have (which this replaces).
|
||||
async function completeWithModelConfirm(
|
||||
ctx: OnboardingContext,
|
||||
providerLabel: string,
|
||||
preferredSlugs: string[],
|
||||
onFail: (reason: null | string) => void
|
||||
) {
|
||||
await ctx.requestGateway('reload.env').catch(() => undefined)
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
if (runtime.ready) {
|
||||
notifyReady(providerName)
|
||||
if (!runtime.ready) {
|
||||
onFail(runtime.reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const defaults = await fetchProviderDefaultModel(preferredSlugs)
|
||||
|
||||
if (!defaults) {
|
||||
// Couldn't get a sensible default — proceed without confirm step.
|
||||
notifyReady(providerLabel)
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
} else {
|
||||
onFail(runtime.reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the default model BEFORE showing the confirm card so that:
|
||||
// (1) "current default: X" shown in the UI is what's actually written
|
||||
// to config — no lying.
|
||||
// (2) If the user clicks "Start chatting" without changing anything,
|
||||
// no extra write is needed.
|
||||
// (3) If they bail out (e.g., refresh the page), they still end up
|
||||
// with a working config, not an empty-model fallback.
|
||||
try {
|
||||
await setModelAssignment({
|
||||
scope: 'main',
|
||||
provider: defaults.providerSlug,
|
||||
model: defaults.defaultModel
|
||||
})
|
||||
} catch {
|
||||
// Persistence failed — still show the confirm card so the user can
|
||||
// pick something explicitly. The backend will pick its own default
|
||||
// at chat time if we end up never persisting.
|
||||
}
|
||||
|
||||
setFlow({
|
||||
status: 'confirming_model',
|
||||
providerSlug: defaults.providerSlug,
|
||||
currentModel: defaults.defaultModel,
|
||||
label: providerLabel,
|
||||
saving: false
|
||||
})
|
||||
}
|
||||
|
||||
function providerResolutionFailure(reason: null | string) {
|
||||
|
|
@ -241,7 +349,7 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
|
|||
if (status === 'approved') {
|
||||
clearPoll()
|
||||
setFlow({ status: 'success', provider })
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
|
|
@ -281,7 +389,7 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
|
|||
|
||||
if (resp.ok && resp.status === 'approved') {
|
||||
setFlow({ status: 'success', provider })
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
|
|
@ -360,7 +468,7 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
|
|||
}
|
||||
|
||||
const { provider } = flow
|
||||
await reloadAndConnect(ctx, provider.name, reason =>
|
||||
await completeWithModelConfirm(ctx, provider.name, [provider.id], reason =>
|
||||
setFlow({
|
||||
status: 'error',
|
||||
provider,
|
||||
|
|
@ -382,7 +490,14 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
|||
await setEnvVar(envKey, trimmed)
|
||||
let stillFailing = false
|
||||
let runtimeFailure: null | string = null
|
||||
await reloadAndConnect(ctx, label, reason => {
|
||||
// For API-key flows we don't have a definitive provider id (the
|
||||
// user picked which API key they're entering, but the corresponding
|
||||
// backend slug — e.g. OPENROUTER_API_KEY → "openrouter" — is the
|
||||
// env-key prefix stripped). Pass a couple of likely candidates;
|
||||
// fetchProviderDefaultModel falls back to the first authenticated
|
||||
// provider returned by /api/model/options if none match.
|
||||
const slugCandidates = [envKey.replace(/_API_KEY$/, '').toLowerCase(), label.toLowerCase()]
|
||||
await completeWithModelConfirm(ctx, label, slugCandidates, reason => {
|
||||
stillFailing = true
|
||||
runtimeFailure = reason
|
||||
})
|
||||
|
|
@ -403,3 +518,53 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
|||
return { ok: false, message: errMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
// User picked a different model from the dropdown on the confirm card.
|
||||
// Persists immediately so the displayed value is always what's on disk.
|
||||
export async function setOnboardingModel(model: string) {
|
||||
const { flow } = $desktopOnboarding.get()
|
||||
|
||||
if (flow.status !== 'confirming_model') {
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update so the dropdown feels instant; revert on failure.
|
||||
const previous = flow.currentModel
|
||||
setFlow({ ...flow, currentModel: model, saving: true })
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
scope: 'main',
|
||||
provider: flow.providerSlug,
|
||||
model
|
||||
})
|
||||
const current = $desktopOnboarding.get().flow
|
||||
|
||||
if (current.status === 'confirming_model') {
|
||||
setFlow({ ...current, currentModel: model, saving: false })
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not change model')
|
||||
const current = $desktopOnboarding.get().flow
|
||||
|
||||
if (current.status === 'confirming_model') {
|
||||
setFlow({ ...current, currentModel: previous, saving: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User clicked "Start chatting" on the confirm card. Finalizes onboarding
|
||||
// — the model was already persisted by completeWithModelConfirm (or by
|
||||
// setOnboardingModel if they changed it), so all that's left is to mark
|
||||
// onboarding done and unblock the rest of the app.
|
||||
export function confirmOnboardingModel(ctx: OnboardingContext) {
|
||||
const { flow } = $desktopOnboarding.get()
|
||||
|
||||
if (flow.status !== 'confirming_model') {
|
||||
return
|
||||
}
|
||||
|
||||
notifyReady(flow.label)
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue