import { onMount, tick } from 'svelte';
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/components/ui/utils';
- import { portalToBody } from '$lib/utils';
import {
modelsStore,
modelOptions,
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
- import { DialogModelInformation } from '$lib/components/app';
- import {
- MENU_MAX_WIDTH,
- MENU_OFFSET,
- VIEWPORT_GUTTER
- } from '$lib/constants/floating-ui-constraints';
+ import { DialogModelInformation, SearchInput } from '$lib/components/app';
+ import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
return options.some((option) => option.model === currentModel);
});
+ let searchTerm = $state('');
+ let searchInputRef = $state<HTMLInputElement | null>(null);
+ let highlightedIndex = $state<number>(-1);
+
+ let filteredOptions: ModelOption[] = $derived(
+ (() => {
+ const term = searchTerm.trim().toLowerCase();
+ if (!term) return options;
+
+ return options.filter(
+ (option) =>
+ option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
+ );
+ })()
+ );
+
+ // Get indices of compatible options for keyboard navigation
+ let compatibleIndices = $derived(
+ filteredOptions
+ .map((option, index) => (isModelCompatible(option) ? index : -1))
+ .filter((i) => i !== -1)
+ );
+
+ // Reset highlighted index when search term changes
+ $effect(() => {
+ void searchTerm;
+ highlightedIndex = -1;
+ });
+
let isOpen = $state(false);
let showModelDialog = $state(false);
- let container: HTMLDivElement | null = null;
- let menuRef = $state<HTMLDivElement | null>(null);
- let triggerButton = $state<HTMLButtonElement | null>(null);
- let menuPosition = $state<{
- top: number;
- left: number;
- width: number;
- placement: 'top' | 'bottom';
- maxHeight: number;
- } | null>(null);
-
- onMount(async () => {
- try {
- await modelsStore.fetch();
- } catch (error) {
+
+ onMount(() => {
+ modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
- }
+ });
});
- function toggleOpen() {
+ function handleOpenChange(open: boolean) {
if (loading || updating) return;
- if (isRouter) {
- // Router mode: show dropdown
- if (isOpen) {
- closeMenu();
- } else {
- openMenu();
+ if (open) {
+ isOpen = true;
+ searchTerm = '';
+ highlightedIndex = -1;
+
+ // Focus search input after popover opens
+ tick().then(() => {
+ requestAnimationFrame(() => searchInputRef?.focus());
+ });
+
+ if (isRouter) {
+ modelsStore.fetchRouterModels().then(() => {
+ modelsStore.fetchModalitiesForLoadedModels();
+ });
}
} else {
- // Single model mode: show dialog
- showModelDialog = true;
+ isOpen = false;
+ searchTerm = '';
+ highlightedIndex = -1;
}
}
- async function openMenu() {
+ function handleTriggerClick() {
if (loading || updating) return;
- isOpen = true;
- await tick();
- updateMenuPosition();
- requestAnimationFrame(() => updateMenuPosition());
-
- if (isRouter) {
- modelsStore.fetchRouterModels().then(() => {
- modelsStore.fetchModalitiesForLoadedModels();
- });
+ if (!isRouter) {
+ // Single model mode: show dialog instead of popover
+ showModelDialog = true;
}
+ // For router mode, the Popover handles open/close
}
export function open() {
if (isRouter) {
- openMenu();
+ handleOpenChange(true);
} else {
showModelDialog = true;
}
}
function closeMenu() {
- if (!isOpen) return;
-
- isOpen = false;
- menuPosition = null;
+ handleOpenChange(false);
}
- function handlePointerDown(event: PointerEvent) {
- if (!container) return;
+ function handleSearchKeyDown(event: KeyboardEvent) {
+ if (event.isComposing) return;
- const target = event.target as Node | null;
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ if (compatibleIndices.length === 0) return;
- if (target && !container.contains(target) && !(menuRef && menuRef.contains(target))) {
- closeMenu();
- }
- }
-
- function handleKeydown(event: KeyboardEvent) {
- if (event.key === 'Escape') {
- closeMenu();
- }
- }
-
- function handleResize() {
- if (isOpen) {
- updateMenuPosition();
- }
- }
-
- function updateMenuPosition() {
- if (!isOpen || !triggerButton || !menuRef) return;
-
- const triggerRect = triggerButton.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- if (viewportWidth === 0 || viewportHeight === 0) return;
-
- const scrollWidth = menuRef.scrollWidth;
- const scrollHeight = menuRef.scrollHeight;
-
- const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2);
- const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH);
- const safeMaxWidth =
- constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
- const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
-
- let width = Math.min(
- Math.max(triggerRect.width, scrollWidth, desiredMinWidth),
- safeMaxWidth || 320
- );
-
- const availableBelow = Math.max(
- 0,
- viewportHeight - VIEWPORT_GUTTER - triggerRect.bottom - MENU_OFFSET
- );
- const availableAbove = Math.max(0, triggerRect.top - VIEWPORT_GUTTER - MENU_OFFSET);
- const viewportAllowance = Math.max(0, viewportHeight - VIEWPORT_GUTTER * 2);
- const fallbackAllowance = Math.max(1, viewportAllowance > 0 ? viewportAllowance : scrollHeight);
-
- function computePlacement(placement: 'top' | 'bottom') {
- const available = placement === 'bottom' ? availableBelow : availableAbove;
- const allowedHeight =
- available > 0 ? Math.min(available, fallbackAllowance) : fallbackAllowance;
- const maxHeight = Math.min(scrollHeight, allowedHeight);
- const height = Math.max(0, maxHeight);
-
- let top: number;
- if (placement === 'bottom') {
- const rawTop = triggerRect.bottom + MENU_OFFSET;
- const minTop = VIEWPORT_GUTTER;
- const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
- if (maxTop < minTop) {
- top = minTop;
- } else {
- top = Math.min(Math.max(rawTop, minTop), maxTop);
- }
+ const currentPos = compatibleIndices.indexOf(highlightedIndex);
+ if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
+ highlightedIndex = compatibleIndices[0];
} else {
- const rawTop = triggerRect.top - MENU_OFFSET - height;
- const minTop = VIEWPORT_GUTTER;
- const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
- if (maxTop < minTop) {
- top = minTop;
- } else {
- top = Math.max(Math.min(rawTop, maxTop), minTop);
- }
+ highlightedIndex = compatibleIndices[currentPos + 1];
}
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ if (compatibleIndices.length === 0) return;
- return { placement, top, height, maxHeight };
- }
-
- const belowMetrics = computePlacement('bottom');
- const aboveMetrics = computePlacement('top');
-
- let metrics = belowMetrics;
- if (scrollHeight > belowMetrics.maxHeight && aboveMetrics.maxHeight > belowMetrics.maxHeight) {
- metrics = aboveMetrics;
- }
-
- let left = triggerRect.right - width;
- const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
- if (maxLeft < VIEWPORT_GUTTER) {
- left = VIEWPORT_GUTTER;
- } else {
- if (left > maxLeft) {
- left = maxLeft;
+ const currentPos = compatibleIndices.indexOf(highlightedIndex);
+ if (currentPos === -1 || currentPos === 0) {
+ highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
+ } else {
+ highlightedIndex = compatibleIndices[currentPos - 1];
}
- if (left < VIEWPORT_GUTTER) {
- left = VIEWPORT_GUTTER;
+ } else if (event.key === 'Enter') {
+ event.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
+ const option = filteredOptions[highlightedIndex];
+ if (isModelCompatible(option)) {
+ handleSelect(option.id);
+ }
+ } else if (compatibleIndices.length > 0) {
+ // No selection - highlight first compatible option
+ highlightedIndex = compatibleIndices[0];
}
}
-
- menuPosition = {
- top: Math.round(metrics.top),
- left: Math.round(left),
- width: Math.round(width),
- placement: metrics.placement,
- maxHeight: Math.round(metrics.maxHeight)
- };
}
async function handleSelect(modelId: string) {
if (shouldCloseMenu) {
closeMenu();
+
+ // Focus the chat textarea after model selection
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector<HTMLTextAreaElement>(
+ '[data-slot="chat-form"] textarea'
+ );
+ textarea?.focus();
+ });
}
}
}
</script>
-<svelte:window onresize={handleResize} />
-<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
-
-<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
+<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
{#if loading && options.length === 0 && isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{:else}
{@const selectedOption = getDisplayOption()}
- <div class="relative">
- <button
- type="button"
+ <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+ <Popover.Trigger
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache()
: isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
- isOpen ? 'text-foreground' : '',
- className
+ isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
- aria-haspopup={isRouter ? 'listbox' : undefined}
- aria-expanded={isRouter ? isOpen : undefined}
- onclick={toggleOpen}
- bind:this={triggerButton}
- disabled={disabled || updating}
+ onclick={handleTriggerClick}
+ disabled={disabled || updating || !isRouter}
>
<Package class="h-3.5 w-3.5" />
{:else if isRouter}
<ChevronDown class="h-3 w-3.5" />
{/if}
- </button>
-
- {#if isOpen && isRouter}
- <div
- bind:this={menuRef}
- use:portalToBody
- class={cn(
- 'fixed z-[1000] overflow-hidden rounded-md border bg-popover shadow-lg transition-opacity',
- menuPosition ? 'opacity-100' : 'pointer-events-none opacity-0'
- )}
- role="listbox"
- style:top={menuPosition ? `${menuPosition.top}px` : undefined}
- style:left={menuPosition ? `${menuPosition.left}px` : undefined}
- style:width={menuPosition ? `${menuPosition.width}px` : undefined}
- data-placement={menuPosition?.placement ?? 'bottom'}
- >
+ </Popover.Trigger>
+
+ <Popover.Content
+ class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+ align="end"
+ sideOffset={8}
+ collisionPadding={16}
+ >
+ <div class="flex max-h-[50dvh] flex-col overflow-hidden">
+ <div
+ class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
+ >
+ <SearchInput
+ id="model-search"
+ placeholder="Search models..."
+ bind:value={searchTerm}
+ bind:ref={searchInputRef}
+ onClose={closeMenu}
+ onKeyDown={handleSearchKeyDown}
+ />
+ </div>
<div
- class="overflow-y-auto py-1"
- style:max-height={menuPosition && menuPosition.maxHeight > 0
- ? `${menuPosition.maxHeight}px`
- : undefined}
+ class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
>
{#if !isCurrentModelInCache() && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
- class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-3 py-2 text-left text-sm text-red-400"
+ class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
- {#each options as option (option.id)}
+ {#if filteredOptions.length === 0}
+ <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
+ {/if}
+ {#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isCompatible = isModelCompatible(option)}
+ {@const isHighlighted = index === highlightedIndex}
{@const missingModalities = getMissingModalities(option)}
+
<div
class={cn(
- 'group flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition focus:outline-none',
+ 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
isCompatible
? 'cursor-pointer hover:bg-muted focus:bg-muted'
: 'cursor-not-allowed opacity-50',
- isSelected
+ isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: isCompatible
? 'hover:bg-accent hover:text-accent-foreground'
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
- aria-selected={isSelected}
+ aria-selected={isSelected || isHighlighted}
aria-disabled={!isCompatible}
tabindex={isCompatible ? 0 : -1}
onclick={() => isCompatible && handleSelect(option.id)}
+ onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
{/each}
</div>
</div>
- {/if}
- </div>
+ </Popover.Content>
+ </Popover.Root>
{/if}
</div>