From b25db9b27e0ae6185f93eda886f55a68af838fed Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 13:37:35 +0100 Subject: [PATCH 01/10] add model search --- app/components/chat/ModelSelector.tsx | 129 +++++++++++++++++++++----- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 521ccac3..918b681d 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -1,6 +1,7 @@ import type { ProviderInfo } from '~/types/model'; -import { useEffect } from 'react'; +import { useEffect, useState, useRef } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; +import { classNames } from '~/utils/classNames'; interface ModelSelectorProps { model?: string; @@ -22,12 +23,30 @@ export const ModelSelector = ({ providerList, modelLoading, }: ModelSelectorProps) => { - // Load enabled providers from cookies + const [modelSearchQuery, setModelSearchQuery] = useState(''); + const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); + const searchInputRef = useRef(null); + + // Filter models based on search query + const filteredModels = [...modelList] + .filter((e) => e.provider === provider?.name && e.name) + .filter( + (model) => + model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) || + model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()), + ); + + // Focus search input when dropdown opens + useEffect(() => { + if (isModelDropdownOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isModelDropdownOpen]); // Update enabled providers when cookies change useEffect(() => { // If current provider is disabled, switch to first enabled provider - if (providerList.length == 0) { + if (providerList.length === 0) { return; } @@ -80,27 +99,91 @@ export const ModelSelector = ({ ))} - setModelSearchQuery(e.target.value)} + placeholder="Search models..." + className={classNames( + 'w-full pl-8 pr-3 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()} + /> +
+ +
+ + + +
+ {modelLoading === 'all' || modelLoading === provider?.name ? ( +
Loading...
+ ) : filteredModels.length === 0 ? ( +
No models found
+ ) : ( + filteredModels.map((modelOption, index) => ( +
{ + e.stopPropagation(); + setModel?.(modelOption.name); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + }} + > + {modelOption.label} +
+ )) + )} +
+ )} - + ); }; From d56708966ac3b592e8e945281660ff05eae37803 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 13:45:26 +0100 Subject: [PATCH 02/10] fix srollbar --- app/components/chat/ModelSelector.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 918b681d..4cad4d44 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -150,9 +150,21 @@ export const ModelSelector = ({
{modelLoading === 'all' || modelLoading === provider?.name ? ( From cb58db3bf02cdc0749cd59129358f9f1354c6178 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 13:51:11 +0100 Subject: [PATCH 03/10] fix ui add keyboard events --- app/components/chat/ModelSelector.tsx | 103 ++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 4cad4d44..13d2622a 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -1,5 +1,6 @@ import type { ProviderInfo } from '~/types/model'; import { useEffect, useState, useRef } from 'react'; +import type { KeyboardEvent } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; import { classNames } from '~/utils/classNames'; @@ -25,7 +26,9 @@ 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)[]>([]); // Filter models based on search query const filteredModels = [...modelList] @@ -36,6 +39,11 @@ export const ModelSelector = ({ model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()), ); + // Reset focused index when search query changes or dropdown opens/closes + useEffect(() => { + setFocusedIndex(-1); + }, [modelSearchQuery, isModelDropdownOpen]); + // Focus search input when dropdown opens useEffect(() => { if (isModelDropdownOpen && searchInputRef.current) { @@ -43,6 +51,73 @@ export const ModelSelector = ({ } }, [isModelDropdownOpen]); + // Handle keyboard navigation + const handleKeyDown = (e: KeyboardEvent) => { + if (!isModelDropdownOpen) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev + 1; + + if (next >= filteredModels.length) { + return 0; + } + + return next; + }); + break; + + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev - 1; + + if (next < 0) { + return filteredModels.length - 1; + } + + return next; + }); + break; + + case 'Enter': + e.preventDefault(); + + if (focusedIndex >= 0 && focusedIndex < filteredModels.length) { + const selectedModel = filteredModels[focusedIndex]; + setModel?.(selectedModel.name); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + } + + break; + + case 'Escape': + e.preventDefault(); + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + break; + + case 'Tab': + if (!e.shiftKey && focusedIndex === filteredModels.length - 1) { + setIsModelDropdownOpen(false); + } + + break; + } + }; + + // Focus the selected option + useEffect(() => { + if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) { + optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); + // Update enabled providers when cookies change useEffect(() => { // If current provider is disabled, switch to first enabled provider @@ -100,7 +175,7 @@ export const ModelSelector = ({ ))} -
+
setIsModelDropdownOpen(!isModelDropdownOpen)} + role="combobox" + aria-expanded={isModelDropdownOpen} + aria-controls="model-listbox" + aria-haspopup="listbox" >
{modelList.find((m) => m.name === model)?.label || 'Select model'} @@ -123,7 +202,11 @@ export const ModelSelector = ({
{isModelDropdownOpen && ( -
+
e.stopPropagation()} + role="searchbox" + aria-label="Search models" />
@@ -150,8 +235,6 @@ export const ModelSelector = ({
(
(optionsRef.current[index] = el)} key={index} + role="option" + aria-selected={model === modelOption.name} className={classNames( 'px-3 py-2 text-sm cursor-pointer', 'hover:bg-bolt-elements-background-depth-3', 'text-bolt-elements-textPrimary', - model === modelOption.name ? 'bg-bolt-elements-background-depth-2' : undefined, + 'outline-none', + model === modelOption.name || focusedIndex === index + ? 'bg-bolt-elements-background-depth-2' + : undefined, + focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, )} onClick={(e) => { e.stopPropagation(); @@ -187,6 +275,7 @@ export const ModelSelector = ({ setIsModelDropdownOpen(false); setModelSearchQuery(''); }} + tabIndex={focusedIndex === index ? 0 : -1} > {modelOption.label}
From 24bf34c683a5d234008b5c27f77315b3d2c98a13 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 15:59:37 +0100 Subject: [PATCH 04/10] fix: Size of dropdowns should be always the same and not break into 2 lines --- app/components/chat/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 13d2622a..d332b5cd 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -181,7 +181,7 @@ export const ModelSelector = ({ 'w-full p-2 rounded-lg border border-bolt-elements-borderColor', 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary', 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus', - 'transition-all cursor-pointer', + 'transition-all cursor-pointer truncate', isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined, )} onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)} From 0f6bfca9bc5ea7ad5672f3b36b350ce899650c48 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 16:07:33 +0100 Subject: [PATCH 05/10] remove truncate --- app/components/chat/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index d332b5cd..13d2622a 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -181,7 +181,7 @@ export const ModelSelector = ({ 'w-full p-2 rounded-lg border border-bolt-elements-borderColor', 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary', 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus', - 'transition-all cursor-pointer truncate', + 'transition-all cursor-pointer', isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined, )} onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)} From 95de84c41a0eedb028f6fafa8e8ff0c7a43cedef Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 16:15:36 +0100 Subject: [PATCH 06/10] add whitespace-nowrap --- app/components/chat/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 13d2622a..cbb4b171 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -190,7 +190,7 @@ export const ModelSelector = ({ aria-controls="model-listbox" aria-haspopup="listbox" > -
+
{modelList.find((m) => m.name === model)?.label || 'Select model'} Date: Sat, 15 Feb 2025 16:42:40 +0100 Subject: [PATCH 07/10] fix truncation --- app/components/chat/ModelSelector.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index cbb4b171..49f1bfab 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -3,6 +3,7 @@ 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; @@ -190,11 +191,11 @@ export const ModelSelector = ({ aria-controls="model-listbox" aria-haspopup="listbox" > -
- {modelList.find((m) => m.name === model)?.label || 'Select model'} - +
{modelList.find((m) => m.name === model)?.label || 'Select model'}
+
From f94be5b383f699c0c3f8fefadf6c406a93284cc7 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 16:50:15 +0100 Subject: [PATCH 08/10] remove transparency --- app/components/chat/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 49f1bfab..880f900f 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -204,7 +204,7 @@ export const ModelSelector = ({ {isModelDropdownOpen && (
From 2056625cbdfc6396dbdb804ce0b737caf635ed8a Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 16:55:55 +0100 Subject: [PATCH 09/10] Enhance accessibility for ModelSelector dropdown Added keyboard support for toggling the dropdown using Enter or Space keys, improving accessibility. Also set appropriate focus properties by adding tabindex to the combobox element. --- app/components/chat/ModelSelector.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 880f900f..4743199a 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -186,10 +186,17 @@ export const ModelSelector = ({ isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined, )} onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsModelDropdownOpen(!isModelDropdownOpen); + } + }} role="combobox" aria-expanded={isModelDropdownOpen} aria-controls="model-listbox" aria-haspopup="listbox" + tabIndex={0} >
{modelList.find((m) => m.name === model)?.label || 'Select model'}
From e717d251912437a86758a5bb6d8a9023fc08deb3 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 16:59:58 +0100 Subject: [PATCH 10/10] Add click outside handler to close ModelSelector dropdown --- app/components/chat/ModelSelector.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 4743199a..b80bfc8b 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -30,6 +30,20 @@ export const ModelSelector = ({ const [focusedIndex, setFocusedIndex] = useState(-1); const searchInputRef = useRef(null); const optionsRef = useRef<(HTMLDivElement | null)[]>([]); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsModelDropdownOpen(false); + setModelSearchQuery(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); // Filter models based on search query const filteredModels = [...modelList] @@ -176,7 +190,7 @@ export const ModelSelector = ({ ))} -
+