Improve model selector with searchable dropdown and keyboard navigation

- Replace inline input with button showing current model
- Add searchable dropdown with comprehensive filtering (name, provider, IDs)
- Fix Cmd+Shift+M keyboard shortcut to open dropdown
- Add arrow key navigation with visual highlights
- Auto-focus search field when dropdown opens
- Use Kobalte Combobox with proper structure for better accessibility
This commit is contained in:
Shantur Rathore
2025-10-24 12:26:25 +01:00
parent 1903bea1c8
commit 7dbda45fb9
2 changed files with 77 additions and 41 deletions

View File

@@ -399,12 +399,21 @@ const App: Component = () => {
keywords: ["model", "llm", "ai"], keywords: ["model", "llm", "ai"],
shortcut: { key: "M", meta: true, shift: true }, shortcut: { key: "M", meta: true, shift: true },
action: () => { action: () => {
const modelControl = document.querySelector("[data-model-selector]") as HTMLElement const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
modelControl?.click() if (modelInput) {
setTimeout(() => { modelInput.focus()
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement setTimeout(() => {
modelInput?.focus() const event = new KeyboardEvent("keydown", {
}, 100) key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
}, },
}) })
@@ -570,8 +579,21 @@ const App: Component = () => {
handleCycleAgent, handleCycleAgent,
handleCycleAgentReverse, handleCycleAgentReverse,
() => { () => {
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
modelInput?.focus() if (modelInput) {
modelInput.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
}, },
() => { () => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
@@ -617,9 +639,9 @@ const App: Component = () => {
const isInCombobox = target.closest('[role="combobox"]') !== null const isInCombobox = target.closest('[role="combobox"]') !== null
const isInListbox = target.closest('[role="listbox"]') !== null const isInListbox = target.closest('[role="listbox"]') !== null
const isInSelect = target.closest('[role="button"][data-agent-selector]') !== null const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null
if (isInCombobox || isInListbox || isInSelect) { if (isInCombobox || isInListbox || isInAgentSelect) {
return return
} }

View File

@@ -1,8 +1,8 @@
import { Combobox } from "@kobalte/core/combobox" import { Combobox } from "@kobalte/core/combobox"
import { For, Show, createEffect, createMemo } from "solid-js" import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions" import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Provider, Model } from "../types/session" import type { Model } from "../types/session"
import Kbd from "./kbd" import Kbd from "./kbd"
interface ModelSelectorProps { interface ModelSelectorProps {
@@ -14,11 +14,15 @@ interface ModelSelectorProps {
interface FlatModel extends Model { interface FlatModel extends Model {
providerName: string providerName: string
key: string
searchText: string
} }
export default function ModelSelector(props: ModelSelectorProps) { export default function ModelSelector(props: ModelSelectorProps) {
const instanceProviders = () => providers().get(props.instanceId) || [] const instanceProviders = () => providers().get(props.instanceId) || []
let inputRef!: HTMLInputElement const [isOpen, setIsOpen] = createSignal(false)
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
createEffect(() => { createEffect(() => {
if (instanceProviders().length === 0) { if (instanceProviders().length === 0) {
@@ -26,16 +30,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
} }
}) })
const handleFocus = (e: FocusEvent) => {
const input = e.target as HTMLInputElement
input.select()
}
const allModels = createMemo<FlatModel[]>(() => const allModels = createMemo<FlatModel[]>(() =>
instanceProviders().flatMap((p) => instanceProviders().flatMap((p) =>
p.models.map((m) => ({ p.models.map((m) => ({
...m, ...m,
providerName: p.name, providerName: p.name,
key: `${m.providerId}/${m.id}`,
searchText: `${m.name} ${p.name} ${m.providerId} ${m.id} ${m.providerId}/${m.id}`,
})), })),
), ),
) )
@@ -46,29 +47,38 @@ export default function ModelSelector(props: ModelSelectorProps) {
const handleChange = async (value: FlatModel | null) => { const handleChange = async (value: FlatModel | null) => {
if (!value) return if (!value) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
if (value.providerId !== props.currentModel.providerId || value.id !== props.currentModel.modelId) {
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
} }
const customFilter = (option: FlatModel, inputValue: string) => {
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
}
createEffect(() => {
if (isOpen()) {
setTimeout(() => {
searchInputRef?.focus()
}, 100)
}
})
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Combobox <Combobox<FlatModel>
value={currentModelValue()} value={currentModelValue()}
onChange={handleChange} onChange={handleChange}
onOpenChange={setIsOpen}
options={allModels()} options={allModels()}
optionValue={(m) => `${m.providerId}/${m.id}`} optionValue="key"
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`} optionTextValue="searchText"
optionLabel="name" optionLabel="name"
placeholder="Search models..." placeholder="Search models..."
defaultFilter="contains" defaultFilter={customFilter}
triggerMode="focus" allowsEmptyCollection
allowsEmptyCollection={false}
itemComponent={(itemProps) => ( itemComponent={(itemProps) => (
<Combobox.Item <Combobox.Item
item={itemProps.item} item={itemProps.item}
class="px-3 py-2 cursor-pointer hover:bg-gray-100 data-[highlighted]:bg-blue-100 rounded outline-none flex items-start gap-2" class="px-3 py-2 cursor-pointer hover:bg-blue-50 rounded outline-none data-[highlighted]:bg-blue-100 flex items-start gap-2"
> >
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<Combobox.ItemLabel class="font-medium text-sm text-gray-900"> <Combobox.ItemLabel class="font-medium text-sm text-gray-900">
@@ -87,16 +97,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Item> </Combobox.Item>
)} )}
> >
<Combobox.Control <Combobox.Control class="relative" data-model-selector-control>
data-model-selector <Combobox.Input class="sr-only" data-model-selector />
class="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]" <Combobox.Trigger
> ref={triggerRef}
<Combobox.Input class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[140px]"
ref={inputRef} >
onFocus={handleFocus} <span class="text-gray-700">Model: {currentModelValue()?.name ?? "None"}</span>
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
/>
<Combobox.Trigger class="flex items-center justify-center">
<Combobox.Icon> <Combobox.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" /> <ChevronDown class="w-3 h-3 text-gray-500" />
</Combobox.Icon> </Combobox.Icon>
@@ -104,8 +111,15 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Control> </Combobox.Control>
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]"> <Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg overflow-hidden z-50 min-w-[300px]">
<Combobox.Listbox class="max-h-80 overflow-auto" /> <div class="p-2 border-b border-gray-200">
<Combobox.Input
ref={searchInputRef}
class="w-full px-3 py-1.5 text-xs border border-gray-300 rounded outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search models..."
/>
</div>
<Combobox.Listbox class="max-h-64 overflow-auto p-1" />
</Combobox.Content> </Combobox.Content>
</Combobox.Portal> </Combobox.Portal>
</Combobox> </Combobox>