From f81027316e7af8c0cffe9332bbdef52b9f9df911 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 7 May 2026 20:04:51 +0100 Subject: [PATCH] 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 --- packages/ui/src/components/model-selector.tsx | 103 +++++++++++++++--- .../ui/src/styles/components/selector.css | 4 +- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index 6f5c5955..363bfe79 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -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(() => + [...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() for (const item of uiState().models.favorites ?? []) { @@ -70,7 +99,7 @@ export default function ModelSelector(props: ModelSelectorProps) { const favoriteModels = createMemo(() => { 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(() => { if (!favoritesOnlyEnabled()) { - return allModels() + return sortedModels() } return favoriteModels() }) - const handleChange = async (value: FlatModel | null) => { - if (!value) return + const groupedVisibleOptions = createMemo(() => { + const query = searchActive() ? inputValue().trim().toLowerCase() : "" + const groups = new Map() + 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(() => + 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 (