hermes-agent/apps/desktop/src/components/model-picker.tsx
emozilla 2252160dcf 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.
2026-05-11 22:01:26 -04:00

222 lines
7.2 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
import { getGlobalModelOptions } from '../hermes'
import { cn } from '../lib/utils'
import { InlineNotice } from './notifications'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton'
interface ModelPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
gw?: HermesGateway
sessionId?: string | null
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({
open,
onOpenChange,
gw,
sessionId,
currentModel,
currentProvider,
onSelect,
contentClassName
}: ModelPickerDialogProps) {
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'],
queryFn: () => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId
})
}
return getGlobalModelOptions()
},
enabled: open
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onOpenChange(false)
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<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">
current: {optionsModel || currentModel || '(unknown)'}
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
</DialogDescription>
</DialogHeader>
<Command className="rounded-none bg-card">
<CommandInput autoFocus placeholder="Filter providers and models..." />
<CommandList className="max-h-96">
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
<ModelResults
currentModel={optionsModel || currentModel}
currentProvider={optionsProvider || currentProvider}
error={error}
loading={loading}
onSelectModel={selectModel}
providers={providers}
/>
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
</label>
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function ModelResults({
loading,
error,
providers,
currentModel,
currentProvider,
onSelectModel
}: {
loading: boolean
error: string | null
providers: ModelOptionProvider[]
currentModel: string
currentProvider: string
onSelectModel: (provider: ModelOptionProvider, model: string) => void
}) {
if (loading) {
return <LoadingResults />
}
if (error) {
return (
<div className="px-3 py-3">
<InlineNotice kind="error" title="Could not load models">
{error}
</InlineNotice>
</div>
)
}
if (providers.length === 0) {
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
}
return (
<>
{providers.map(provider => {
const models = provider.models ?? []
if (models.length === 0) {
return null
}
return (
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
{provider.warning && (
<div className="px-2 pb-2">
<InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning">
{provider.warning}
</InlineNotice>
</div>
)}
{models.map(model => {
const isCurrent = model === currentModel && provider.slug === currentProvider
return (
<CommandItem
className={cn(
'pl-6 font-mono',
isCurrent &&
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
)}
key={`${provider.slug}:${model}`}
onSelect={() => onSelectModel(provider, model)}
value={`${provider.name} ${provider.slug} ${model}`}
>
<span className="min-w-0 flex-1 truncate">{model}</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
</>
)
}
function LoadingResults() {
return (
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
{Array.from({ length: 4 }, (_, rowIndex) => (
<div className="rounded-sm py-1.5 pl-6 pr-2" key={rowIndex}>
<Skeleton className={cn('h-5', rowIndex % 3 === 0 ? 'w-3/5' : rowIndex % 3 === 1 ? 'w-4/5' : 'w-1/2')} />
</div>
))}
</CommandGroup>
)
}
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
return (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{provider.name}</span>
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
</span>
</span>
)
}