refactor): provider dropdown and model selector

Refactor the existing provider selector to improve code clarity and match the model selection dropdown.
This commit is contained in:
KevIsDev 2025-04-30 01:57:47 +01:00
parent e6dae47ce4
commit a83f864fa1

View File

@ -3,7 +3,6 @@ import { useEffect, useState, useRef } from 'react';
import type { KeyboardEvent } from 'react'; import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types'; import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import * as React from 'react';
interface ModelSelectorProps { interface ModelSelectorProps {
model?: string; model?: string;
@ -27,17 +26,28 @@ export const ModelSelector = ({
}: ModelSelectorProps) => { }: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState(''); const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null); const modelSearchInputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<(HTMLDivElement | null)[]>([]); const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null); const modelDropdownRef = useRef<HTMLDivElement>(null);
const [providerSearchQuery, setProviderSearchQuery] = useState('');
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
const providerSearchInputRef = useRef<HTMLInputElement>(null);
const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const providerDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
setModelSearchQuery(''); setModelSearchQuery('');
} }
if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) {
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
}
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
@ -45,7 +55,6 @@ export const ModelSelector = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Filter models based on search query
const filteredModels = [...modelList] const filteredModels = [...modelList]
.filter((e) => e.provider === provider?.name && e.name) .filter((e) => e.provider === provider?.name && e.name)
.filter( .filter(
@ -54,20 +63,31 @@ export const ModelSelector = ({
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()), model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
); );
// Reset focused index when search query changes or dropdown opens/closes const filteredProviders = providerList.filter((p) =>
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
);
useEffect(() => { useEffect(() => {
setFocusedIndex(-1); setFocusedModelIndex(-1);
}, [modelSearchQuery, isModelDropdownOpen]); }, [modelSearchQuery, isModelDropdownOpen]);
// Focus search input when dropdown opens
useEffect(() => { useEffect(() => {
if (isModelDropdownOpen && searchInputRef.current) { setFocusedProviderIndex(-1);
searchInputRef.current.focus(); }, [providerSearchQuery, isProviderDropdownOpen]);
useEffect(() => {
if (isModelDropdownOpen && modelSearchInputRef.current) {
modelSearchInputRef.current.focus();
} }
}, [isModelDropdownOpen]); }, [isModelDropdownOpen]);
// Handle keyboard navigation useEffect(() => {
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (isProviderDropdownOpen && providerSearchInputRef.current) {
providerSearchInputRef.current.focus();
}
}, [isProviderDropdownOpen]);
const handleModelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isModelDropdownOpen) { if (!isModelDropdownOpen) {
return; return;
} }
@ -75,50 +95,30 @@ export const ModelSelector = ({
switch (e.key) { switch (e.key) {
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => { setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1));
const next = prev + 1;
if (next >= filteredModels.length) {
return 0;
}
return next;
});
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => { setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1));
const next = prev - 1;
if (next < 0) {
return filteredModels.length - 1;
}
return next;
});
break; break;
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) { if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) {
const selectedModel = filteredModels[focusedIndex]; const selectedModel = filteredModels[focusedModelIndex];
setModel?.(selectedModel.name); setModel?.(selectedModel.name);
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
setModelSearchQuery(''); setModelSearchQuery('');
} }
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
setModelSearchQuery(''); setModelSearchQuery('');
break; break;
case 'Tab': case 'Tab':
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) { if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
} }
@ -126,25 +126,76 @@ export const ModelSelector = ({
} }
}; };
// Focus the selected option const handleProviderKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
useEffect(() => { if (!isProviderDropdownOpen) {
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) { return;
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
} }
}, [focusedIndex]);
// Update enabled providers when cookies change switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedProviderIndex((prev) => (prev + 1 >= filteredProviders.length ? 0 : prev + 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedProviderIndex((prev) => (prev - 1 < 0 ? filteredProviders.length - 1 : prev - 1));
break;
case 'Enter':
e.preventDefault();
if (focusedProviderIndex >= 0 && focusedProviderIndex < filteredProviders.length) {
const selectedProvider = filteredProviders[focusedProviderIndex];
if (setProvider) {
setProvider(selectedProvider);
const firstModel = modelList.find((m) => m.provider === selectedProvider.name);
if (firstModel && setModel) {
setModel(firstModel.name);
}
}
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
}
break;
case 'Escape':
e.preventDefault();
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
setIsProviderDropdownOpen(false);
}
break;
}
};
useEffect(() => {
if (focusedModelIndex >= 0 && modelOptionsRef.current[focusedModelIndex]) {
modelOptionsRef.current[focusedModelIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedModelIndex]);
useEffect(() => {
if (focusedProviderIndex >= 0 && providerOptionsRef.current[focusedProviderIndex]) {
providerOptionsRef.current[focusedProviderIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedProviderIndex]);
useEffect(() => { useEffect(() => {
// If current provider is disabled, switch to first enabled provider
if (providerList.length === 0) { if (providerList.length === 0) {
return; return;
} }
if (provider && !providerList.map((p) => p.name).includes(provider.name)) { if (provider && !providerList.some((p) => p.name === provider.name)) {
const firstEnabledProvider = providerList[0]; const firstEnabledProvider = providerList[0];
setProvider?.(firstEnabledProvider); setProvider?.(firstEnabledProvider);
// Also update the model to the first available one for the new provider
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name); const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
if (firstModel) { if (firstModel) {
@ -165,32 +216,136 @@ export const ModelSelector = ({
} }
return ( return (
<div className="mb-2 flex gap-2 flex-col sm:flex-row"> <div className="flex gap-2 flex-col sm:flex-row">
<select {/* Provider Combobox */}
value={provider?.name ?? ''} <div className="relative flex w-full" onKeyDown={handleProviderKeyDown} ref={providerDropdownRef}>
onChange={(e) => { <div
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value); className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
if (newProvider && setProvider) { 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
setProvider(newProvider); 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
'transition-all cursor-pointer',
isProviderDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
)}
onClick={() => setIsProviderDropdownOpen(!isProviderDropdownOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsProviderDropdownOpen(!isProviderDropdownOpen);
} }
}}
role="combobox"
aria-expanded={isProviderDropdownOpen}
aria-controls="provider-listbox"
aria-haspopup="listbox"
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{provider?.name || 'Select provider'}</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
isProviderDropdownOpen ? 'rotate-180' : undefined,
)}
/>
</div>
</div>
const firstModel = [...modelList].find((m) => m.provider === e.target.value); {isProviderDropdownOpen && (
<div
className="absolute z-20 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
role="listbox"
id="provider-listbox"
>
<div className="px-2 pb-2">
<div className="relative">
<input
ref={providerSearchInputRef}
type="text"
value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)}
placeholder="Search providers..."
className={classNames(
'w-full pl-2 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
'transition-all',
)}
onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search providers"
/>
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
</div>
</div>
<div
className={classNames(
'max-h-60 overflow-y-auto',
'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full',
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
)}
>
{filteredProviders.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
) : (
filteredProviders.map((providerOption, index) => (
<div
ref={(el) => (providerOptionsRef.current[index] = el)}
key={providerOption.name}
role="option"
aria-selected={provider?.name === providerOption.name}
className={classNames(
'px-3 py-2 text-sm cursor-pointer',
'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary',
'outline-none',
provider?.name === providerOption.name || focusedProviderIndex === index
? 'bg-bolt-elements-background-depth-2'
: undefined,
focusedProviderIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
)}
onClick={(e) => {
e.stopPropagation();
if (setProvider) {
setProvider(providerOption);
const firstModel = modelList.find((m) => m.provider === providerOption.name);
if (firstModel && setModel) { if (firstModel && setModel) {
setModel(firstModel.name); setModel(firstModel.name);
} }
}} }
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
>
{providerList.map((provider: ProviderInfo) => (
<option key={provider.name} value={provider.name}>
{provider.name}
</option>
))}
</select>
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}> setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
{providerOption.name}
</div>
))
)}
</div>
</div>
)}
</div>
{/* Model Combobox */}
<div className="relative flex w-full min-w-[70%]" onKeyDown={handleModelKeyDown} ref={modelDropdownRef}>
<div <div
className={classNames( className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor', 'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
@ -232,13 +387,13 @@ export const ModelSelector = ({
<div className="px-2 pb-2"> <div className="px-2 pb-2">
<div className="relative"> <div className="relative">
<input <input
ref={searchInputRef} ref={modelSearchInputRef}
type="text" type="text"
value={modelSearchQuery} value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)} onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..." placeholder="Search models..."
className={classNames( className={classNames(
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm', 'w-full pl-2 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
@ -277,8 +432,8 @@ export const ModelSelector = ({
) : ( ) : (
filteredModels.map((modelOption, index) => ( filteredModels.map((modelOption, index) => (
<div <div
ref={(el) => (optionsRef.current[index] = el)} ref={(el) => (modelOptionsRef.current[index] = el)}
key={index} key={index} // Consider using modelOption.name if unique
role="option" role="option"
aria-selected={model === modelOption.name} aria-selected={model === modelOption.name}
className={classNames( className={classNames(
@ -286,10 +441,10 @@ export const ModelSelector = ({
'hover:bg-bolt-elements-background-depth-3', 'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary', 'text-bolt-elements-textPrimary',
'outline-none', 'outline-none',
model === modelOption.name || focusedIndex === index model === modelOption.name || focusedModelIndex === index
? 'bg-bolt-elements-background-depth-2' ? 'bg-bolt-elements-background-depth-2'
: undefined, : undefined,
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, focusedModelIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -297,7 +452,7 @@ export const ModelSelector = ({
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
setModelSearchQuery(''); setModelSearchQuery('');
}} }}
tabIndex={focusedIndex === index ? 0 : -1} tabIndex={focusedModelIndex === index ? 0 : -1}
> >
{modelOption.label} {modelOption.label}
</div> </div>