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"],
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const modelControl = document.querySelector("[data-model-selector]") as HTMLElement
modelControl?.click()
setTimeout(() => {
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement
modelInput?.focus()
}, 100)
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
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)
}
},
})
@@ -570,8 +579,21 @@ const App: Component = () => {
handleCycleAgent,
handleCycleAgentReverse,
() => {
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement
modelInput?.focus()
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
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
@@ -617,9 +639,9 @@ const App: Component = () => {
const isInCombobox = target.closest('[role="combobox"]') !== 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
}

View File

@@ -1,8 +1,8 @@
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 { ChevronDown } from "lucide-solid"
import type { Provider, Model } from "../types/session"
import type { Model } from "../types/session"
import Kbd from "./kbd"
interface ModelSelectorProps {
@@ -14,11 +14,15 @@ interface ModelSelectorProps {
interface FlatModel extends Model {
providerName: string
key: string
searchText: string
}
export default function ModelSelector(props: ModelSelectorProps) {
const instanceProviders = () => providers().get(props.instanceId) || []
let inputRef!: HTMLInputElement
const [isOpen, setIsOpen] = createSignal(false)
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
createEffect(() => {
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[]>(() =>
instanceProviders().flatMap((p) =>
p.models.map((m) => ({
...m,
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) => {
if (!value) return
if (value.providerId !== props.currentModel.providerId || value.id !== props.currentModel.modelId) {
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
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 (
<div class="flex items-center gap-2">
<Combobox
<Combobox<FlatModel>
value={currentModelValue()}
onChange={handleChange}
onOpenChange={setIsOpen}
options={allModels()}
optionValue={(m) => `${m.providerId}/${m.id}`}
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
optionValue="key"
optionTextValue="searchText"
optionLabel="name"
placeholder="Search models..."
defaultFilter="contains"
triggerMode="focus"
allowsEmptyCollection={false}
defaultFilter={customFilter}
allowsEmptyCollection
itemComponent={(itemProps) => (
<Combobox.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">
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
@@ -87,16 +97,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Item>
)}
>
<Combobox.Control
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.Input
ref={inputRef}
onFocus={handleFocus}
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.Control class="relative" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector />
<Combobox.Trigger
ref={triggerRef}
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]"
>
<span class="text-gray-700">Model: {currentModelValue()?.name ?? "None"}</span>
<Combobox.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" />
</Combobox.Icon>
@@ -104,8 +111,15 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Control>
<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.Listbox class="max-h-80 overflow-auto" />
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg overflow-hidden z-50 min-w-[300px]">
<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.Portal>
</Combobox>