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:
42
src/App.tsx
42
src/App.tsx
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user