b846f0c002
DialogContent published itself as the portal container for popovers opened inside a dialog (so focus stays in the dialog and dismissal doesn't close it), but that same element carried `overflow-y-auto`. Every Select/Popover/ DropdownMenu in a dialog was therefore born inside a scroll box and got cropped at the dialog's edge — most visibly the worktree dialog's base-branch combobox, where the branch list was cut off entirely and only the search field showed. Split the box in two: the shell keeps position/size/skin and no longer clips (it stays the portal container), while a new inner body div owns layout and scrolling. Popovers remain DOM descendants of the dialog, so focus and dismissal behave exactly as before, but they can now paint past the dialog's bounds. The banner variant had the same `overflow-hidden` on its shell; its clip moves to the banner itself, which keeps the rounded bottom edge. Callers that passed layout/scroll classes (grid, gap-*, p-*, overflow-*) now pass them via the new `bodyClassName`; `className` keeps sizing and skin.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
|
|
import { useI18n } from '@/i18n'
|
|
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
|
import { modelSearchText } from '@/lib/model-search-text'
|
|
import { currentPickerSelection } from '@/lib/model-status-label'
|
|
import { normalize } from '@/lib/text'
|
|
import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'
|
|
|
|
import type { HermesGateway } from '../hermes'
|
|
import { cn } from '../lib/utils'
|
|
import { startManualOnboarding } from '../store/onboarding'
|
|
|
|
import { InlineNotice } from './notifications'
|
|
import { Button } from './ui/button'
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
|
import { HighlightMatches } from './ui/highlight-matches'
|
|
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 }) => void
|
|
profile?: string
|
|
/**
|
|
* Optional class for DialogContent. Use it to lift the picker onto a higher
|
|
* rung of the overlay ladder when it opens over another fixed overlay (the
|
|
* desktop onboarding overlay, say) — on the default modal rung it renders
|
|
* underneath and blocks pointer events.
|
|
*/
|
|
contentClassName?: string
|
|
}
|
|
|
|
export function ModelPickerDialog({
|
|
open,
|
|
onOpenChange,
|
|
gw,
|
|
sessionId,
|
|
currentModel,
|
|
currentProvider,
|
|
onSelect,
|
|
profile = 'default',
|
|
contentClassName
|
|
}: ModelPickerDialogProps) {
|
|
const { t } = useI18n()
|
|
const copy = t.modelPicker
|
|
// Own the search term so we can filter manually. cmdk's built-in
|
|
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
|
// an empty query), which destroys the backend's curated order. We disable
|
|
// it and do a plain substring filter that preserves array order — matching
|
|
// the `hermes model` CLI picker, which shows the curated list verbatim.
|
|
const [search, setSearch] = useState('')
|
|
|
|
const modelOptions = useQuery({
|
|
queryKey: modelOptionsQueryKey(profile, sessionId),
|
|
queryFn: () => requestModelOptions({ gateway: gw, sessionId }),
|
|
enabled: open
|
|
})
|
|
|
|
const providers = modelOptions.data?.providers ?? []
|
|
|
|
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
|
{ model: currentModel, provider: currentProvider },
|
|
modelOptions.data
|
|
)
|
|
|
|
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 })
|
|
onOpenChange(false)
|
|
}
|
|
|
|
// Open the full onboarding provider selector to add/switch a provider.
|
|
// Reuses the entire onboarding flow (OAuth rows, API-key form, device-code,
|
|
// model-confirm) instead of duplicating provider UI here. Closes the picker
|
|
// so the onboarding overlay isn't rendered underneath it.
|
|
const addProvider = () => {
|
|
startManualOnboarding()
|
|
onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
<DialogContent
|
|
bodyClassName="gap-0 overflow-hidden p-0"
|
|
className={cn('max-h-[85vh] max-w-2xl', contentClassName)}
|
|
>
|
|
<DialogHeader className="border-b border-border px-4 py-3">
|
|
<DialogTitle>{copy.title}</DialogTitle>
|
|
<DialogDescription className="font-mono text-xs leading-relaxed">
|
|
{copy.current} {optionsModel || currentModel || copy.unknown}
|
|
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Command className="rounded-none bg-card" shouldFilter={false}>
|
|
<CommandInput autoFocus onValueChange={setSearch} placeholder={copy.search} value={search} />
|
|
<CommandList className="max-h-96">
|
|
{!loading && !error && <CommandEmpty>{copy.noModels}</CommandEmpty>}
|
|
<ModelResults
|
|
currentModel={optionsModel || currentModel}
|
|
currentProvider={optionsProvider || currentProvider}
|
|
error={error}
|
|
loading={loading}
|
|
onSelectModel={selectModel}
|
|
providers={providers}
|
|
search={search}
|
|
/>
|
|
</CommandList>
|
|
</Command>
|
|
|
|
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
|
|
<Button onClick={addProvider} variant="ghost">
|
|
{copy.addProvider}
|
|
</Button>
|
|
<Button onClick={() => onOpenChange(false)} variant="outline">
|
|
{t.common.cancel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function ModelResults({
|
|
loading,
|
|
error,
|
|
providers,
|
|
currentModel,
|
|
currentProvider,
|
|
onSelectModel,
|
|
search
|
|
}: {
|
|
loading: boolean
|
|
error: string | null
|
|
providers: ModelOptionProvider[]
|
|
currentModel: string
|
|
currentProvider: string
|
|
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
|
search: string
|
|
}) {
|
|
const { t } = useI18n()
|
|
const copy = t.modelPicker
|
|
|
|
if (loading) {
|
|
return <LoadingResults />
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="px-3 py-3">
|
|
<InlineNotice kind="error" title={copy.loadFailed}>
|
|
{error}
|
|
</InlineNotice>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (providers.length === 0) {
|
|
return <div className="px-4 py-6 text-sm text-muted-foreground">{copy.noAuthenticatedProviders}</div>
|
|
}
|
|
|
|
const q = normalize(search)
|
|
|
|
const matches = (provider: ModelOptionProvider, model: string) =>
|
|
!q ||
|
|
modelSearchText(model).toLowerCase().includes(q) ||
|
|
provider.name.toLowerCase().includes(q) ||
|
|
provider.slug.toLowerCase().includes(q)
|
|
|
|
// Only configured providers (those with curated models) are selectable
|
|
// here. Switching to a NOT-yet-configured provider goes through the
|
|
// "Add provider" footer button, which opens the full onboarding selector.
|
|
const configured = providers.filter(p => (p.models ?? []).length > 0)
|
|
|
|
return (
|
|
<>
|
|
{configured.map(provider => {
|
|
// Preserve the backend's curated order — filter in place, no re-sort.
|
|
const models = (provider.models ?? []).filter(m => matches(provider, m))
|
|
|
|
if (models.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const unavailable = new Set(provider.unavailable_models ?? [])
|
|
|
|
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
|
|
const price = provider.pricing?.[model]
|
|
const locked = unavailable.has(model)
|
|
|
|
return (
|
|
<CommandItem
|
|
className={cn(
|
|
'flex items-center gap-2 pl-6 font-mono',
|
|
isCurrent &&
|
|
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
|
|
locked && 'cursor-not-allowed opacity-45'
|
|
)}
|
|
disabled={locked}
|
|
key={`${provider.slug}:${model}`}
|
|
onSelect={() => {
|
|
if (!locked) {
|
|
onSelectModel(provider, model)
|
|
}
|
|
}}
|
|
value={`${provider.slug}:${model}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
<HighlightMatches query={search} text={model} />
|
|
</span>
|
|
{locked && (
|
|
<span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>
|
|
)}
|
|
<ModelPrice isCurrent={isCurrent} price={price} />
|
|
</CommandItem>
|
|
)
|
|
})}
|
|
{unavailable.size > 0 && (
|
|
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
|
{copy.proNeedsSubscription}
|
|
</div>
|
|
)}
|
|
</CommandGroup>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
|
// Renders nothing when pricing is unavailable for the model.
|
|
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
|
const { t } = useI18n()
|
|
const copy = t.modelPicker
|
|
|
|
if (!price || (!price.input && !price.output)) {
|
|
return null
|
|
}
|
|
|
|
if (price.free) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
|
|
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
|
)}
|
|
>
|
|
{copy.free}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const onSale = typeof price.discount_percent === 'number' && Boolean(price.was_input || price.was_output)
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'shrink-0 inline-flex items-center gap-1.5 text-[0.66rem] tabular-nums',
|
|
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
|
)}
|
|
title={copy.priceTitle}
|
|
>
|
|
{onSale ? (
|
|
<span
|
|
className={cn(
|
|
'rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold',
|
|
isCurrent ? 'bg-primary-foreground/20' : 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
|
|
)}
|
|
>
|
|
-{price.discount_percent}%
|
|
</span>
|
|
) : null}
|
|
<span>
|
|
{price.input || '?'} / {price.output || '?'}
|
|
</span>
|
|
{onSale ? (
|
|
<span
|
|
className={cn(
|
|
'line-through decoration-from-font opacity-70',
|
|
isCurrent ? 'text-primary-foreground/60' : 'text-muted-foreground/80'
|
|
)}
|
|
>
|
|
{copy.wasPrice} {price.was_input || '?'} / {price.was_output || '?'}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 }) {
|
|
const { t } = useI18n()
|
|
const copy = t.modelPicker
|
|
|
|
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
|
|
const tierBadge =
|
|
provider.free_tier === true ? (
|
|
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
|
{copy.freeTier}
|
|
</span>
|
|
) : provider.free_tier === false ? (
|
|
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
|
{copy.pro}
|
|
</span>
|
|
) : null
|
|
|
|
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>
|
|
{tierBadge}
|
|
</span>
|
|
)
|
|
}
|