AI Model Selector
Picks the model a conversation runs on, with descriptions and availability.
Installation
Terminal
pnpm dlx @dowel-ui/cli add ai-model-selectorInstalls select as well, because this component imports it.
No npm packages are needed beyond what Dowel already requires.
Accessibility
Inherits Select's listbox keyboard model. Each option sets textValue to the model name so typeahead matches the name rather than the description. A disabled model shows and announces why it is unavailable — a disabled option with no reason reads as a broken interface.
Props
ModelSelector
| Prop | Type | Default |
|---|---|---|
models (required) | ModelOption[] | — |
defaultValue | string | — |
onValueChange | (value: string) => void | — |
placeholder | string | "Select a model" |
value | string | — |
Quality
7/7 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — does not apply
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add ai-model-selector writes into your project, with imports rewritten to your own path alias.
ui/ai-model-selector.tsx
"use client";
import type { ComponentPropsWithRef, ReactNode } from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { cn } from "@/lib/utils";
/**
* Picks the model a conversation runs on.
*
* A thin, opinionated layer over Select rather than a new control: model
* pickers differ between products in what they show, not in how they behave.
* What this adds is the shape of the option — a name, a short description, and
* the fact that some models are unavailable on the current plan — which is the
* part every implementation gets wrong in the same way, by hiding the reason a
* model cannot be chosen.
*/
export interface ModelOption {
id: string;
name: string;
/** One line on what the model is for. */
description?: string;
/** Grouping, for example a provider name. */
group?: string;
disabled?: boolean;
/**
* Why it is unavailable.
*
* Shown and announced alongside the name. A disabled option with no reason
* makes the user think the interface is broken.
*/
disabledReason?: string;
badge?: ReactNode;
}
export interface ModelSelectorProps extends Omit<
ComponentPropsWithRef<typeof SelectTrigger>,
"children"
> {
models: ModelOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
}
export function ModelSelector({
className,
models,
value,
defaultValue,
onValueChange,
placeholder = "Select a model",
...props
}: ModelSelectorProps) {
// Preserves the order the caller gave, rather than sorting group names.
const groups = new Map<string, ModelOption[]>();
for (const model of models) {
const key = model.group ?? "";
groups.set(key, [...(groups.get(key) ?? []), model]);
}
return (
<Select value={value} defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger
data-slot="model-selector"
className={cn("w-auto min-w-44 gap-2", className)}
{...props}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="min-w-64">
{[...groups].map(([group, groupModels]) => (
<SelectGroup key={group || "ungrouped"}>
{group ? <SelectLabel>{group}</SelectLabel> : null}
{groupModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
disabled={model.disabled}
textValue={model.name}
// Only the name reaches the trigger; the description stays in
// the list where there is room for it.
label={model.name}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-2">{model.badge}</span>
{model.disabled && model.disabledReason ? (
<span className="text-2xs text-muted-foreground">
{model.disabledReason}
</span>
) : model.description ? (
<span className="text-2xs text-muted-foreground">{model.description}</span>
) : null}
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}