Files
zoeissleeping 57e7a92cd1 fix: UI polish and provider fixes
- Add animate-pulse CSS animation for loading states
- Add Ctrl+I/Ctrl+B markdown shortcuts in chat input
- Improve chat input resize with mirror element
- Add reasoning duration display and shimmer effect
- Add confirmation dialog for deleting all models
- Simplify RowVirtualizerDynamic resize handling
- Fix DialogType imports (type -> value)
- Fix ollama cloud model name resolution
- Make vllm auth header optional
- Various icon and styling fixes
2026-05-11 17:44:00 -05:00

309 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import { DialogType } from '~/composables/useDialog';
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog();
const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts();
const props = defineProps<{
providers: ProviderWithModels[];
addHotkey?: boolean;
}>();
const selectedModel = defineModel<ModelWithProvider | null>();
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);
const { floatingStyles, placement, middlewareData } = useFloating(dropdownButton, dropdownContent, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(6),
flip(),
shift({ padding: 10 }),
size({
apply({ availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.min(availableHeight - 20, 460)}px`,
});
},
padding: 10,
}),
hide()
],
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith('top')
? 'transform-origin-bottom-center'
: 'transform-origin-top-center'
);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const searchQuery = ref('');
const filteredProviders = computed(() => {
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
});
const flatOptions = computed(() => {
type FlatItem =
| { type: 'header'; id: string; provider: Provider }
| { type: 'model'; id: string; model: Model; provider: Provider };
const options: FlatItem[] = [];
for (const provider of filteredProviders.value) {
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 modelOnlyOptions = computed(() =>
flatOptions.value.filter(o => o.type === 'model')
);
const setFocusToModel = (index: number) => {
if (modelOnlyOptions.value.length === 0) return;
navigatingWithKeyboard.value = true;
const clampedIndex = Math.max(0, Math.min(index, modelOnlyOptions.value.length - 1));
focusedOptionId.value = modelOnlyOptions.value[clampedIndex]!.id;
scrollFocusedIntoView();
};
const scrollFocusedIntoView = () => {
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: Model, provider: Provider) => {
selectedModel.value = { ...model, provider };
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);
if (!modelStillExists) {
let firstEnabled: ModelWithProvider | null = null;
for (const provider of props.providers) {
const enabledModel = provider.models.find(m => m.enabled);
if (enabledModel) {
firstEnabled = { ...enabledModel, provider };
break;
}
}
selectedModel.value = firstEnabled;
}
}
});
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
searchInputRef.value?.focus();
});
} else {
focusedOptionId.value = null;
searchQuery.value = '';
}
});
const closeDropdown = () => {
isOpen.value = false;
navigatingWithKeyboard.value = false;
};
const handleSearchKeyDown = (event: KeyboardEvent) => {
if (modelOnlyOptions.value.length === 0) return;
const currentModelIndex = focusedOptionId.value
? modelOnlyOptions.value.findIndex(o => o.id === focusedOptionId.value)
: -1;
switch (event.key) {
case 'Escape':
closeDropdown();
event.preventDefault();
break;
case 'ArrowDown':
event.preventDefault();
if (currentModelIndex === -1 || currentModelIndex === modelOnlyOptions.value.length - 1) {
setFocusToModel(0);
} else {
setFocusToModel(currentModelIndex + 1);
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentModelIndex === -1 || currentModelIndex === 0) {
setFocusToModel(modelOnlyOptions.value.length - 1);
} else {
setFocusToModel(currentModelIndex - 1);
}
break;
case 'Home':
event.preventDefault();
setFocusToModel(0);
break;
case 'End':
event.preventDefault();
setFocusToModel(modelOnlyOptions.value.length - 1);
break;
case 'Enter':
event.preventDefault();
handleSelectFromFocused();
break;
}
};
let unbindShortcut: () => void = () => { };
if (props.addHotkey) {
unbindShortcut = addShortcut(['ctrl', 'shift', 'm'], (event) => {
event.preventDefault();
event.stopPropagation();
toggleDropdown();
});
}
onUnmounted(() => {
unbindShortcut();
});
</script>
<template>
<div role="button" @click="toggleDropdown()" ref="dropdownButton"
class="cursor-pointer flex items-center w-fit min-w-0 select-none gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[
isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
]">
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" />
<span class="max-w-full truncate">
{{ selectedModel ? selectedModel.name : 'Select a model' }}
</span>
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"></span>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
<KeepAlive>
<div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox"
aria-label="Select model"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
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">
<span
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
@keydown="handleSearchKeyDown" type="text" placeholder="Search models..."
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 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>
<template v-else>
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
<template v-slot="{ item }">
<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)]">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
<span class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span>
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
</button>
</div>
</div>
</KeepAlive>
</Transition>
</Teleport>
</template>