import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { AgentState, Model } from "../workspace/types"; type SelectableModel = Model & { provider: string; id: string }; type ModelGroup = { provider: string; models: SelectableModel[] }; function isSelectableModel(model: Model): model is SelectableModel { return Boolean(model.provider?.trim() && model.id?.trim()); } function modelName(model: SelectableModel) { return model.name?.trim() || model.id; } function modelMatches(model: SelectableModel, query: string) { if (!query) return true; return [model.provider, model.id, model.name] .filter((value): value is string => Boolean(value)) .some((value) => value.toLocaleLowerCase().includes(query)); } function groupModels(models: SelectableModel[], query: string): ModelGroup[] { const byProvider = new Map(); for (const model of models) { if (!modelMatches(model, query)) continue; const providerModels = byProvider.get(model.provider) ?? []; providerModels.push(model); byProvider.set(model.provider, providerModels); } return [...byProvider.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([provider, providerModels]) => ({ provider, models: providerModels.sort((left, right) => modelName(left).localeCompare(modelName(right)), ), })); } function ProviderGroup({ group, current, headingId, onSelect, disabled, }: { group: ModelGroup; current?: AgentState["model"]; headingId: string; onSelect: (model: SelectableModel) => void; disabled: boolean; }) { return (
{group.provider}
{group.models.map((model) => { const name = modelName(model); return ( ); })}
); } export function ModelSelector({ models, current, onSelect, pending = false, }: { models: Model[]; current?: AgentState["model"]; onSelect: (model: Model) => Promise; pending?: boolean; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [selecting, setSelecting] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); const searchRef = useRef(null); const labelId = useId(); const valueId = useId(); const menuId = useId(); const availableModels = useMemo( () => models.filter(isSelectableModel), [models], ); const groups = useMemo( () => groupModels(availableModels, query.trim().toLocaleLowerCase()), [availableModels, query], ); const resultCount = groups.reduce( (count, group) => count + group.models.length, 0, ); const selectedModel = availableModels.find( (model) => model.provider === current?.provider && model.id === current?.id, ); const selectedName = selectedModel ? modelName(selectedModel) : current?.name?.trim() || current?.id; const selectedLabel = current?.provider ? `${current.provider} · ${selectedName ?? "Current model"}` : "Current model"; const busy = pending || selecting; useEffect(() => { if (!open) return; const focusFrame = window.requestAnimationFrame(() => searchRef.current?.focus(), ); const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); setOpen(false); window.requestAnimationFrame(() => triggerRef.current?.focus()); }; const closeOutside = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; window.addEventListener("keydown", closeOnEscape); window.addEventListener("pointerdown", closeOutside); return () => { window.cancelAnimationFrame(focusFrame); window.removeEventListener("keydown", closeOnEscape); window.removeEventListener("pointerdown", closeOutside); }; }, [open]); const chooseModel = async (model: SelectableModel) => { if (busy) return; setSelecting(true); try { if (!(await onSelect(model))) return; setOpen(false); window.requestAnimationFrame(() => triggerRef.current?.focus()); } finally { setSelecting(false); } }; return (
Model
{open && ( )}
); }