Adds ability to manually enter model name

This commit is contained in:
2026-04-01 18:13:04 -07:00
parent be6641de24
commit a31c053298

View File

@@ -119,9 +119,8 @@ function loadStoredModelPreferences() {
} }
} }
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { function pickProviderModel(options: string[], preferred: string | null) {
if (fallback && options.includes(fallback)) return fallback; if (preferred?.trim()) return preferred.trim();
if (preferred && options.includes(preferred)) return preferred;
return options[0] ?? ""; return options[0] ?? "";
} }
@@ -197,34 +196,46 @@ type ModelComboboxProps = {
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) { function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [draftValue, setDraftValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const normalizedDraftValue = draftValue.trim();
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = normalizedDraftValue.toLowerCase();
if (!needle) return options; if (!needle) return options;
return options.filter((option) => option.toLowerCase().includes(needle)); return options.filter((option) => option.toLowerCase().includes(needle));
}, [options, query]); }, [normalizedDraftValue, options]);
const hasExactOption = options.includes(normalizedDraftValue);
useEffect(() => {
if (open) return;
setDraftValue(value);
}, [open, value]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
inputRef.current?.focus(); inputRef.current?.focus();
}, [open]); }, [open]);
const commitDraftValue = () => {
onChange(normalizedDraftValue);
setDraftValue(normalizedDraftValue);
setOpen(false);
};
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return; if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false); commitDraftValue();
setQuery("");
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
setOpen(false); setOpen(false);
setQuery(""); setDraftValue(value);
}; };
window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("pointerdown", handlePointerDown);
@@ -233,32 +244,63 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [open]); }, [commitDraftValue, open, value]);
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<button <div className="flex h-9 min-w-56 items-center rounded-md border border-input bg-background px-2 text-sm">
type="button" <input
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm" ref={inputRef}
onClick={() => { value={draftValue}
if (disabled) return; onFocus={() => {
setOpen((current) => !current); if (disabled) return;
}} setDraftValue(value);
disabled={disabled} setOpen(true);
> }}
<span className="truncate text-left">{value || "Select model"}</span> onInput={(event) => {
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" /> setDraftValue(event.currentTarget.value);
</button> setOpen(true);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
commitDraftValue();
}}
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
placeholder="Select or type model"
disabled={disabled}
/>
<button
type="button"
className="ml-2 shrink-0 text-muted-foreground disabled:opacity-50"
onClick={() => {
if (disabled) return;
if (open) {
commitDraftValue();
return;
}
setDraftValue(value);
setOpen(true);
}}
disabled={disabled}
aria-label="Toggle model options"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
{open ? ( {open ? (
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md"> <div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
<input
ref={inputRef}
value={query}
onInput={(event) => setQuery(event.currentTarget.value)}
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
placeholder="Filter models"
/>
<div className="max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto">
{normalizedDraftValue && !hasExactOption ? (
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={commitDraftValue}
>
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">Use "{normalizedDraftValue}"</span>
</button>
) : null}
{filteredOptions.length ? ( {filteredOptions.length ? (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
<button <button
@@ -268,16 +310,16 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
onClick={() => { onClick={() => {
onChange(option); onChange(option);
setOpen(false); setOpen(false);
setQuery(""); setDraftValue(option);
}} }}
> >
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} /> <Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">{option}</span> <span className="truncate">{option}</span>
</button> </button>
)) ))
) : ( ) : !normalizedDraftValue ? (
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p> <p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
)} ) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -545,10 +587,11 @@ export default function App() {
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
useEffect(() => { useEffect(() => {
if (model.trim()) return;
setModel((current) => { setModel((current) => {
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current); return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]);
}); });
}, [provider, providerModelOptions, providerModelPreferences]); }, [model, provider, providerModelOptions, providerModelPreferences]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -1233,7 +1276,7 @@ export default function App() {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider); setProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider); const options = getModelOptions(modelCatalog, nextProvider);
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current)); setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
}} }}
disabled={isSending} disabled={isSending}
> >
@@ -1244,12 +1287,13 @@ export default function App() {
<ModelCombobox <ModelCombobox
options={providerModelOptions} options={providerModelOptions}
value={model} value={model}
disabled={isSending || providerModelOptions.length === 0} disabled={isSending}
onChange={(nextModel) => { onChange={(nextModel) => {
setModel(nextModel); const normalizedModel = nextModel.trim();
setModel(normalizedModel);
setProviderModelPreferences((current) => ({ setProviderModelPreferences((current) => ({
...current, ...current,
[provider]: nextModel, [provider]: normalizedModel || null,
})); }));
}} }}
/> />