From a83f864fa1e9f21ef281f263b615851e100cf069 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Wed, 30 Apr 2025 01:57:47 +0100 Subject: [PATCH] refactor): provider dropdown and model selector Refactor the existing provider selector to improve code clarity and match the model selection dropdown. --- app/components/chat/ModelSelector.tsx | 311 +++++++++++++++++++------- 1 file changed, 233 insertions(+), 78 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index b80bfc8b..8d38b256 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, useRef } from 'react'; import type { KeyboardEvent } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; import { classNames } from '~/utils/classNames'; -import * as React from 'react'; interface ModelSelectorProps { model?: string; @@ -27,17 +26,28 @@ export const ModelSelector = ({ }: ModelSelectorProps) => { const [modelSearchQuery, setModelSearchQuery] = useState(''); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(-1); - const searchInputRef = useRef(null); - const optionsRef = useRef<(HTMLDivElement | null)[]>([]); - const dropdownRef = useRef(null); + const [focusedModelIndex, setFocusedModelIndex] = useState(-1); + const modelSearchInputRef = useRef(null); + const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]); + const modelDropdownRef = useRef(null); + const [providerSearchQuery, setProviderSearchQuery] = useState(''); + const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false); + const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1); + const providerSearchInputRef = useRef(null); + const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]); + const providerDropdownRef = useRef(null); useEffect(() => { 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); setModelSearchQuery(''); } + + if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) { + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + } }; document.addEventListener('mousedown', handleClickOutside); @@ -45,7 +55,6 @@ export const ModelSelector = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Filter models based on search query const filteredModels = [...modelList] .filter((e) => e.provider === provider?.name && e.name) .filter( @@ -54,20 +63,31 @@ export const ModelSelector = ({ 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(() => { - setFocusedIndex(-1); + setFocusedModelIndex(-1); }, [modelSearchQuery, isModelDropdownOpen]); - // Focus search input when dropdown opens useEffect(() => { - if (isModelDropdownOpen && searchInputRef.current) { - searchInputRef.current.focus(); + setFocusedProviderIndex(-1); + }, [providerSearchQuery, isProviderDropdownOpen]); + + useEffect(() => { + if (isModelDropdownOpen && modelSearchInputRef.current) { + modelSearchInputRef.current.focus(); } }, [isModelDropdownOpen]); - // Handle keyboard navigation - const handleKeyDown = (e: KeyboardEvent) => { + useEffect(() => { + if (isProviderDropdownOpen && providerSearchInputRef.current) { + providerSearchInputRef.current.focus(); + } + }, [isProviderDropdownOpen]); + + const handleModelKeyDown = (e: KeyboardEvent) => { if (!isModelDropdownOpen) { return; } @@ -75,50 +95,30 @@ export const ModelSelector = ({ switch (e.key) { case 'ArrowDown': e.preventDefault(); - setFocusedIndex((prev) => { - const next = prev + 1; - - if (next >= filteredModels.length) { - return 0; - } - - return next; - }); + setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1)); break; - case 'ArrowUp': e.preventDefault(); - setFocusedIndex((prev) => { - const next = prev - 1; - - if (next < 0) { - return filteredModels.length - 1; - } - - return next; - }); + setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1)); break; - case 'Enter': e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < filteredModels.length) { - const selectedModel = filteredModels[focusedIndex]; + if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) { + const selectedModel = filteredModels[focusedModelIndex]; setModel?.(selectedModel.name); setIsModelDropdownOpen(false); setModelSearchQuery(''); } break; - case 'Escape': e.preventDefault(); setIsModelDropdownOpen(false); setModelSearchQuery(''); break; - case 'Tab': - if (!e.shiftKey && focusedIndex === filteredModels.length - 1) { + if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) { setIsModelDropdownOpen(false); } @@ -126,25 +126,76 @@ export const ModelSelector = ({ } }; - // Focus the selected option - useEffect(() => { - if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) { - optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + const handleProviderKeyDown = (e: KeyboardEvent) => { + if (!isProviderDropdownOpen) { + return; } - }, [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(() => { - // If current provider is disabled, switch to first enabled provider if (providerList.length === 0) { return; } - if (provider && !providerList.map((p) => p.name).includes(provider.name)) { + if (provider && !providerList.some((p) => p.name === provider.name)) { const firstEnabledProvider = providerList[0]; setProvider?.(firstEnabledProvider); - // Also update the model to the first available one for the new provider const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name); if (firstModel) { @@ -165,32 +216,136 @@ export const ModelSelector = ({ } return ( -
- 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" + /> +
+ +
+
+ - const firstModel = [...modelList].find((m) => m.provider === e.target.value); +
+ {filteredProviders.length === 0 ? ( +
No providers found
+ ) : ( + filteredProviders.map((providerOption, index) => ( +
(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 (firstModel && setModel) { - 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) => ( - - ))} - + if (setProvider) { + setProvider(providerOption); -
+ const firstModel = modelList.find((m) => m.provider === providerOption.name); + + if (firstModel && setModel) { + setModel(firstModel.name); + } + } + + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + }} + tabIndex={focusedProviderIndex === index ? 0 : -1} + > + {providerOption.name} +
+ )) + )} +
+
+ )} + + + {/* Model Combobox */} +
setModelSearchQuery(e.target.value)} placeholder="Search models..." 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', 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', @@ -277,8 +432,8 @@ export const ModelSelector = ({ ) : ( filteredModels.map((modelOption, index) => (
(optionsRef.current[index] = el)} - key={index} + ref={(el) => (modelOptionsRef.current[index] = el)} + key={index} // Consider using modelOption.name if unique role="option" aria-selected={model === modelOption.name} className={classNames( @@ -286,10 +441,10 @@ export const ModelSelector = ({ 'hover:bg-bolt-elements-background-depth-3', 'text-bolt-elements-textPrimary', 'outline-none', - model === modelOption.name || focusedIndex === index + model === modelOption.name || focusedModelIndex === index ? 'bg-bolt-elements-background-depth-2' : 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) => { e.stopPropagation(); @@ -297,7 +452,7 @@ export const ModelSelector = ({ setIsModelDropdownOpen(false); setModelSearchQuery(''); }} - tabIndex={focusedIndex === index ? 0 : -1} + tabIndex={focusedModelIndex === index ? 0 : -1} > {modelOption.label}