Skip to content

Commit a6122ff

Browse files
webui: prevent models selector popover from overflowing viewport
Use Floating UI's auto-positioning with 50dvh height limit and proper collision detection instead of forcing top positioning. Fixes overflow on desktop and mobile keyboard issues
1 parent 8f4656f commit a6122ff

File tree

2 files changed

+140
-134
lines changed

2 files changed

+140
-134
lines changed

tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte

Lines changed: 134 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,10 @@
173173
let isOpen = $state(false);
174174
let showModelDialog = $state(false);
175175
176-
onMount(async () => {
177-
try {
178-
await modelsStore.fetch();
179-
} catch (error) {
176+
onMount(() => {
177+
modelsStore.fetch().catch((error) => {
180178
console.error('Unable to load models:', error);
181-
}
179+
});
182180
});
183181
184182
function handleOpenChange(open: boolean) {
@@ -394,138 +392,140 @@
394392

395393
<Popover.Content
396394
class="w-96 max-w-[calc(100vw-2rem)] p-0"
397-
side="top"
398395
align="end"
399396
sideOffset={8}
397+
collisionPadding={16}
400398
>
401-
<div class="p-4">
402-
<SearchInput
403-
id="model-search"
404-
placeholder="Search models..."
405-
bind:value={searchTerm}
406-
bind:ref={searchInputRef}
407-
onClose={closeMenu}
408-
onKeyDown={handleSearchKeyDown}
409-
/>
410-
</div>
411-
<div class="max-h-80 overflow-y-auto">
412-
{#if !isCurrentModelInCache() && currentModel}
413-
<!-- Show unavailable model as first option (disabled) -->
414-
<button
415-
type="button"
416-
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
417-
role="option"
418-
aria-selected="true"
419-
aria-disabled="true"
420-
disabled
421-
>
422-
<span class="truncate">{selectedOption?.name || currentModel}</span>
423-
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
424-
</button>
425-
<div class="my-1 h-px bg-border"></div>
426-
{/if}
427-
{#if filteredOptions.length === 0}
428-
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
429-
{/if}
430-
{#each filteredOptions as option, index (option.id)}
431-
{@const status = getModelStatus(option.model)}
432-
{@const isLoaded = status === ServerModelStatus.LOADED}
433-
{@const isLoading = status === ServerModelStatus.LOADING}
434-
{@const isSelected = currentModel === option.model || activeId === option.id}
435-
{@const isCompatible = isModelCompatible(option)}
436-
{@const isHighlighted = index === highlightedIndex}
437-
{@const missingModalities = getMissingModalities(option)}
438-
439-
<div
440-
class={cn(
441-
'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
442-
isCompatible
443-
? 'cursor-pointer hover:bg-muted focus:bg-muted'
444-
: 'cursor-not-allowed opacity-50',
445-
isSelected || isHighlighted
446-
? 'bg-accent text-accent-foreground'
447-
: isCompatible
448-
? 'hover:bg-accent hover:text-accent-foreground'
449-
: '',
450-
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
451-
)}
452-
role="option"
453-
aria-selected={isSelected || isHighlighted}
454-
aria-disabled={!isCompatible}
455-
tabindex={isCompatible ? 0 : -1}
456-
onclick={() => isCompatible && handleSelect(option.id)}
457-
onmouseenter={() => (highlightedIndex = index)}
458-
onkeydown={(e) => {
459-
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
460-
e.preventDefault();
461-
handleSelect(option.id);
462-
}
463-
}}
464-
>
465-
<span class="min-w-0 flex-1 truncate">{option.model}</span>
466-
467-
{#if missingModalities}
468-
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
469-
{#if missingModalities.vision}
470-
<Tooltip.Root>
471-
<Tooltip.Trigger>
472-
<EyeOff class="h-3.5 w-3.5" />
473-
</Tooltip.Trigger>
474-
<Tooltip.Content class="z-[9999]">
475-
<p>No vision support</p>
476-
</Tooltip.Content>
477-
</Tooltip.Root>
478-
{/if}
479-
{#if missingModalities.audio}
480-
<Tooltip.Root>
481-
<Tooltip.Trigger>
482-
<MicOff class="h-3.5 w-3.5" />
483-
</Tooltip.Trigger>
484-
<Tooltip.Content class="z-[9999]">
485-
<p>No audio support</p>
486-
</Tooltip.Content>
487-
</Tooltip.Root>
488-
{/if}
489-
</span>
490-
{/if}
491-
492-
{#if isLoading}
493-
<Tooltip.Root>
494-
<Tooltip.Trigger>
495-
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
496-
</Tooltip.Trigger>
497-
<Tooltip.Content class="z-[9999]">
498-
<p>Loading model...</p>
499-
</Tooltip.Content>
500-
</Tooltip.Root>
501-
{:else if isLoaded}
502-
<Tooltip.Root>
503-
<Tooltip.Trigger>
504-
<button
505-
type="button"
506-
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
507-
onclick={(e) => {
508-
e.stopPropagation();
509-
modelsStore.unloadModel(option.model);
510-
}}
511-
>
512-
<span
513-
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
514-
></span>
515-
<Power
516-
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
517-
/>
518-
</button>
519-
</Tooltip.Trigger>
520-
<Tooltip.Content class="z-[9999]">
521-
<p>Unload model</p>
522-
</Tooltip.Content>
523-
</Tooltip.Root>
524-
{:else}
525-
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
526-
{/if}
527-
</div>
528-
{/each}
399+
<div class="flex max-h-[50dvh] flex-col overflow-hidden">
400+
<div class="shrink-0 p-4">
401+
<SearchInput
402+
id="model-search"
403+
placeholder="Search models..."
404+
bind:value={searchTerm}
405+
bind:ref={searchInputRef}
406+
onClose={closeMenu}
407+
onKeyDown={handleSearchKeyDown}
408+
/>
409+
</div>
410+
<div class="min-h-0 flex-1 overflow-y-auto">
411+
{#if !isCurrentModelInCache() && currentModel}
412+
<!-- Show unavailable model as first option (disabled) -->
413+
<button
414+
type="button"
415+
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
416+
role="option"
417+
aria-selected="true"
418+
aria-disabled="true"
419+
disabled
420+
>
421+
<span class="truncate">{selectedOption?.name || currentModel}</span>
422+
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
423+
</button>
424+
<div class="my-1 h-px bg-border"></div>
425+
{/if}
426+
{#if filteredOptions.length === 0}
427+
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
428+
{/if}
429+
{#each filteredOptions as option, index (option.id)}
430+
{@const status = getModelStatus(option.model)}
431+
{@const isLoaded = status === ServerModelStatus.LOADED}
432+
{@const isLoading = status === ServerModelStatus.LOADING}
433+
{@const isSelected = currentModel === option.model || activeId === option.id}
434+
{@const isCompatible = isModelCompatible(option)}
435+
{@const isHighlighted = index === highlightedIndex}
436+
{@const missingModalities = getMissingModalities(option)}
437+
438+
<div
439+
class={cn(
440+
'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
441+
isCompatible
442+
? 'cursor-pointer hover:bg-muted focus:bg-muted'
443+
: 'cursor-not-allowed opacity-50',
444+
isSelected || isHighlighted
445+
? 'bg-accent text-accent-foreground'
446+
: isCompatible
447+
? 'hover:bg-accent hover:text-accent-foreground'
448+
: '',
449+
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
450+
)}
451+
role="option"
452+
aria-selected={isSelected || isHighlighted}
453+
aria-disabled={!isCompatible}
454+
tabindex={isCompatible ? 0 : -1}
455+
onclick={() => isCompatible && handleSelect(option.id)}
456+
onmouseenter={() => (highlightedIndex = index)}
457+
onkeydown={(e) => {
458+
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
459+
e.preventDefault();
460+
handleSelect(option.id);
461+
}
462+
}}
463+
>
464+
<span class="min-w-0 flex-1 truncate">{option.model}</span>
465+
466+
{#if missingModalities}
467+
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
468+
{#if missingModalities.vision}
469+
<Tooltip.Root>
470+
<Tooltip.Trigger>
471+
<EyeOff class="h-3.5 w-3.5" />
472+
</Tooltip.Trigger>
473+
<Tooltip.Content class="z-[9999]">
474+
<p>No vision support</p>
475+
</Tooltip.Content>
476+
</Tooltip.Root>
477+
{/if}
478+
{#if missingModalities.audio}
479+
<Tooltip.Root>
480+
<Tooltip.Trigger>
481+
<MicOff class="h-3.5 w-3.5" />
482+
</Tooltip.Trigger>
483+
<Tooltip.Content class="z-[9999]">
484+
<p>No audio support</p>
485+
</Tooltip.Content>
486+
</Tooltip.Root>
487+
{/if}
488+
</span>
489+
{/if}
490+
491+
{#if isLoading}
492+
<Tooltip.Root>
493+
<Tooltip.Trigger>
494+
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
495+
</Tooltip.Trigger>
496+
<Tooltip.Content class="z-[9999]">
497+
<p>Loading model...</p>
498+
</Tooltip.Content>
499+
</Tooltip.Root>
500+
{:else if isLoaded}
501+
<Tooltip.Root>
502+
<Tooltip.Trigger>
503+
<button
504+
type="button"
505+
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
506+
onclick={(e) => {
507+
e.stopPropagation();
508+
modelsStore.unloadModel(option.model);
509+
}}
510+
>
511+
<span
512+
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
513+
></span>
514+
<Power
515+
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
516+
/>
517+
</button>
518+
</Tooltip.Trigger>
519+
<Tooltip.Content class="z-[9999]">
520+
<p>Unload model</p>
521+
</Tooltip.Content>
522+
</Tooltip.Root>
523+
{:else}
524+
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
525+
{/if}
526+
</div>
527+
{/each}
528+
</div>
529529
</div>
530530
</Popover.Content>
531531
</Popover.Root>

tools/server/webui/src/lib/components/ui/popover/popover-content.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
ref = $bindable(null),
99
class: className,
1010
sideOffset = 4,
11+
side,
1112
align = 'center',
13+
collisionPadding = 8,
14+
avoidCollisions = true,
1215
portalProps,
1316
...restProps
1417
}: PopoverPrimitive.ContentProps & {
@@ -21,7 +24,10 @@
2124
bind:ref
2225
data-slot="popover-content"
2326
{sideOffset}
27+
{side}
2428
{align}
29+
{collisionPadding}
30+
{avoidCollisions}
2531
class={cn(
2632
'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
2733
className

0 commit comments

Comments
 (0)