From 36df1de197840123be76dc50849bfad10d6a6c56 Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:42:57 -0600 Subject: [PATCH] feat: virtualize model selector --- app/components/ModelSelector.vue | 145 ++++++++++++++++++------------- 1 file changed, 83 insertions(+), 62 deletions(-) diff --git a/app/components/ModelSelector.vue b/app/components/ModelSelector.vue index 445eb09..1561968 100644 --- a/app/components/ModelSelector.vue +++ b/app/components/ModelSelector.vue @@ -5,6 +5,7 @@ import type { Entity } from '@triplit/client'; import type schema from '#triplit/schema'; import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import { sortByReleaseDate } from '~/utils/sort'; +import RowVirtualizerFixed from './RowVirtualizerFixed.vue'; const { openDialog } = useDialog(); const { allModels } = useModels(); @@ -21,6 +22,8 @@ const isOpen = ref(false); const dropdownButton = ref(null); const dropdownContent = ref(null); const searchInputRef = ref(null); +const scrollContainerRef = ref(null); +const virtualizerRef = ref | null>(null); const navigatingWithKeyboard = ref(false); const focusedOptionId = ref(null); @@ -61,35 +64,42 @@ const filteredProviders = computed(() => { }); const flatOptions = computed(() => { - const options: { id: string; model: Entity; provider: Entity }[] = []; + type FlatItem = + | { type: 'header'; id: string; provider: Entity } + | { type: 'model'; id: string; model: Entity; provider: Entity }; + + const options: FlatItem[] = []; for (const provider of filteredProviders.value) { - const enabledModels = provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0); - for (const model of enabledModels) { - options.push({ id: model.id, model, provider }); + const enabledModels = provider.models.filter(m => m.enabled).sort(sortByReleaseDate); + if (enabledModels.length > 0) { + options.push({ type: 'header', id: `header-${provider.id}`, provider }); + for (const model of enabledModels) { + options.push({ type: 'model', id: model.id, model, provider }); + } } } return options; }); -const focusedIndex = computed(() => { - if (!focusedOptionId.value) return -1; - return flatOptions.value.findIndex(o => o.id === focusedOptionId.value); -}); +const modelOnlyOptions = computed(() => + flatOptions.value.filter(o => o.type === 'model') +); -const setFocusToOption = (index: number) => { - if (flatOptions.value.length === 0) return; +const setFocusToModel = (index: number) => { + if (modelOnlyOptions.value.length === 0) return; navigatingWithKeyboard.value = true; - - const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1)); - focusedOptionId.value = flatOptions.value[clampedIndex]!.id; + const clampedIndex = Math.max(0, Math.min(index, modelOnlyOptions.value.length - 1)); + focusedOptionId.value = modelOnlyOptions.value[clampedIndex]!.id; scrollFocusedIntoView(); }; const scrollFocusedIntoView = () => { - nextTick(() => { - const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`); - focusedEl?.scrollIntoView({ block: 'nearest' }); - }); + if (!focusedOptionId.value) return; + + const index = flatOptions.value.findIndex(o => o.id === focusedOptionId.value); + if (index !== -1) { + virtualizerRef.value?.scrollToIndex(index, { align: 'start' }); + } }; const selectModel = (model: Entity, provider: Entity) => { @@ -97,6 +107,15 @@ const selectModel = (model: Entity, provider: Entity { + const option = flatOptions.value.find(o => o.id === focusedOptionId.value); + if (option && option.type === 'model') { + selectModel(option.model, option.provider); + } else if (modelOnlyOptions.value.length > 0) { + selectModel(modelOnlyOptions.value[0]!.model, modelOnlyOptions.value[0]!.provider); + } +}; + watch(() => props.providers, () => { if (selectedModel.value) { const modelStillExists = allModels.value.some(model => model.id === selectedModel.value?.id); @@ -134,7 +153,11 @@ const closeDropdown = () => { }; const handleSearchKeyDown = (event: KeyboardEvent) => { - if (flatOptions.value.length === 0) return; + if (modelOnlyOptions.value.length === 0) return; + + const currentModelIndex = focusedOptionId.value + ? modelOnlyOptions.value.findIndex(o => o.id === focusedOptionId.value) + : -1; switch (event.key) { case 'Escape': @@ -143,42 +166,31 @@ const handleSearchKeyDown = (event: KeyboardEvent) => { break; case 'ArrowDown': event.preventDefault(); - if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) { - setFocusToOption(0); + if (currentModelIndex === -1 || currentModelIndex === modelOnlyOptions.value.length - 1) { + setFocusToModel(0); } else { - setFocusToOption(focusedIndex.value + 1); + setFocusToModel(currentModelIndex + 1); } break; case 'ArrowUp': event.preventDefault(); - if (focusedIndex.value === -1 || focusedIndex.value === 0) { - setFocusToOption(flatOptions.value.length - 1); + if (currentModelIndex === -1 || currentModelIndex === 0) { + setFocusToModel(modelOnlyOptions.value.length - 1); } else { - setFocusToOption(focusedIndex.value - 1); + setFocusToModel(currentModelIndex - 1); } break; case 'Home': event.preventDefault(); - setFocusToOption(0); + setFocusToModel(0); break; case 'End': event.preventDefault(); - setFocusToOption(flatOptions.value.length - 1); + setFocusToModel(modelOnlyOptions.value.length - 1); break; case 'Enter': event.preventDefault(); - if (focusedOptionId.value) { - const option = flatOptions.value.find(o => o.id === focusedOptionId.value); - if (option) { - selectModel(option.model, option.provider); - break; - } - } - - if (flatOptions.value.length > 0) { - selectModel(flatOptions.value[0]!.model, flatOptions.value[0]!.provider); - } - + handleSelectFromFocused(); break; } }; @@ -226,7 +238,7 @@ onUnmounted(() => { aria-label="Select model" :aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="floatingStyles" - class="fixed z-30 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden" + class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden" :class="transformOrigin">
@@ -237,34 +249,43 @@ onUnmounted(() => { class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
-
+
No models found
-
-
- {{ provider.name }} - -
- - -
+