feat: virtualize model selector

This commit is contained in:
Zoe
2026-03-03 09:42:57 -06:00
parent da0b652b29
commit 36df1de197
+83 -62
View File
@@ -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<HTMLElement | null>(null);
const dropdownContent = ref<HTMLElement | null>(null);
const searchInputRef = ref<HTMLInputElement | null>(null);
const scrollContainerRef = ref<HTMLDivElement | null>(null);
const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null);
const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null);
@@ -61,35 +64,42 @@ const filteredProviders = computed(() => {
});
const flatOptions = computed(() => {
const options: { id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }[] = [];
type FlatItem =
| { type: 'header'; id: string; provider: Entity<typeof schema, 'providers'> }
| { type: 'model'; id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> };
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<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
@@ -97,6 +107,15 @@ const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<ty
closeDropdown();
};
const handleSelectFromFocused = () => {
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">
<div class="relative">
@@ -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" />
</div>
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none">
<div ref="scrollContainerRef"
class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none">
<div v-if="filteredProviders.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
No models found
</div>
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
<div v-if="provider.models.filter(m => m.enabled).length > 0"
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ provider.name }}
<button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: provider.id })"
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span>
</button>
</div>
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
:key="model.id" :id="`model-option-${model.id}`" role="option"
:aria-selected="focusedOptionId === model.id" @click="selectModel(model, provider)"
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
:class="{
'bg-[var(--color-hover)]': selectedModel?.id === model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
}">
<ModelInfo :model="model" size="medium" />
</button>
</div>
<template v-else>
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
<template v-slot="{ item, index }">
<template v-if="item.type === 'header'">
<div
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ item.provider.name }}
<button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span>
</button>
</div>
</template>
<template v-else>
<button :id="`model-option-${item.model.id}`" role="option"
:aria-selected="focusedOptionId === item.model.id"
@click="selectModel(item.model, item.provider)"
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
:class="{
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard
}">
<ModelInfo :model="item.model" size="medium" />
</button>
</template>
</template>
</RowVirtualizerFixed>
</template>
</div>
<div class="p-1 border-t border-[var(--color-border)]">