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:
@@ -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?.()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user