feat(ui): group model picker by provider

Replace incidental backend model ordering with deterministic UI ordering so long model lists are easier to scan. The picker now sorts providers by provider id, sorts models by display name within each provider, and inserts compact provider headers that show provider names with ids for disambiguation.

Filtering is applied before headers are rendered so visible matches keep their provider context. Header rows are disabled picker options to avoid Kobalte section key collisions while preserving the existing model row layout and favorite controls.

Validation: npm run typecheck --workspace @codenomad/ui
This commit is contained in:
Shantur Rathore 2026-05-07 20:04:51 +01:00
parent a4bb223775
commit f81027316e
2 changed files with 88 additions and 19 deletions

View file

@ -8,7 +8,6 @@ import { getLogger } from "../lib/logger"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
const log = getLogger("session")
interface ModelSelectorProps {
instanceId: string
sessionId: string
@ -22,6 +21,26 @@ interface FlatModel extends Model {
searchText: string
}
interface ModelGroup {
providerId: string
providerName: string
models: FlatModel[]
}
interface ProviderHeaderOption {
type: "header"
key: string
providerId: string
providerName: string
searchText: string
}
type PickerOption = FlatModel | ProviderHeaderOption
const compareIds = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" })
const isProviderHeaderOption = (option: PickerOption): option is ProviderHeaderOption => "type" in option && option.type === "header"
export default function ModelSelector(props: ModelSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || []
@ -57,6 +76,16 @@ export default function ModelSelector(props: ModelSelectorProps) {
),
)
const sortedModels = createMemo<FlatModel[]>(() =>
[...allModels()].sort((left, right) => {
const providerComparison = compareIds(left.providerId, right.providerId)
if (providerComparison !== 0) return providerComparison
const nameComparison = compareIds(left.name, right.name)
if (nameComparison !== 0) return nameComparison
return compareIds(left.id, right.id)
}),
)
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of uiState().models.favorites ?? []) {
@ -70,7 +99,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const favoriteModels = createMemo<FlatModel[]>(() => {
const keys = favoriteKeySet()
if (keys.size === 0) return []
return allModels().filter((m) => keys.has(m.key))
return sortedModels().filter((m) => keys.has(m.key))
})
const hasFavorites = createMemo(() => favoriteModels().length > 0)
@ -104,20 +133,46 @@ export default function ModelSelector(props: ModelSelectorProps) {
const visibleOptions = createMemo<FlatModel[]>(() => {
if (!favoritesOnlyEnabled()) {
return allModels()
return sortedModels()
}
return favoriteModels()
})
const handleChange = async (value: FlatModel | null) => {
if (!value) return
const groupedVisibleOptions = createMemo<ModelGroup[]>(() => {
const query = searchActive() ? inputValue().trim().toLowerCase() : ""
const groups = new Map<string, ModelGroup>()
for (const model of visibleOptions()) {
if (query && !model.searchText.toLowerCase().includes(query)) continue
const existing = groups.get(model.providerId)
if (existing) {
existing.models.push(model)
} else {
groups.set(model.providerId, { providerId: model.providerId, providerName: model.providerName, models: [model] })
}
}
return Array.from(groups.values())
})
const pickerOptions = createMemo<PickerOption[]>(() =>
groupedVisibleOptions().flatMap((group) => [
{
type: "header" as const,
key: `provider:${group.providerId}`,
providerId: group.providerId,
providerName: group.providerName,
searchText: `${group.providerName} ${group.providerId}`,
},
...group.models,
]),
)
const handleChange = async (value: PickerOption | null) => {
if (!value || isProviderHeaderOption(value)) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
const customFilter = (option: FlatModel, rawInput: string) => {
if (!searchDirty()) return true
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
}
const customFilter = () => true
createEffect(() => {
if (isOpen()) {
@ -209,7 +264,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
return (
<div class="sidebar-selector">
<Combobox<FlatModel>
<Combobox<PickerOption>
open={isOpen()}
value={currentModelValue()}
onChange={handleChange}
@ -217,15 +272,29 @@ export default function ModelSelector(props: ModelSelectorProps) {
if (!next && suppressNextClose) return
setIsOpen(next)
}}
options={visibleOptions()}
options={pickerOptions()}
optionValue="key"
optionTextValue="searchText"
optionLabel="name"
optionLabel={(option) => (isProviderHeaderOption(option) ? option.providerName : option.name)}
optionDisabled={isProviderHeaderOption}
placeholder={t("modelSelector.placeholder.search")}
defaultFilter={customFilter}
allowsEmptyCollection
itemComponent={(itemProps) => {
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
if (isProviderHeaderOption(itemProps.item.rawValue)) {
const header = itemProps.item.rawValue
return (
<li class="selector-section" role="presentation">
<span class="selector-section-title" title={header.providerId}>
{header.providerName}
{header.providerName !== header.providerId && <span dir="ltr"> · {header.providerId}</span>}
</span>
</li>
)
}
const model = itemProps.item.rawValue
const isFavorite = () => favoriteKeySet().has(model.key)
return (
<Combobox.Item
item={itemProps.item}
@ -233,9 +302,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
>
<>
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
<Combobox.ItemLabel class="selector-option-label">{model.name}</Combobox.ItemLabel>
<Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
{model.providerName} {model.providerId}/{model.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
@ -269,8 +338,8 @@ export default function ModelSelector(props: ModelSelectorProps) {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: itemProps.item.rawValue.providerId,
modelId: itemProps.item.rawValue.id,
providerId: model.providerId,
modelId: model.id,
})
}}
>

View file

@ -216,13 +216,13 @@
}
.selector-section {
@apply px-3 py-2 border-b;
@apply px-3 py-1 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.selector-section-title {
@apply text-xs font-medium;
@apply block text-[11px] font-medium truncate;
color: var(--text-muted);
}