From 4645ad9df51dcf7da1e50d1de4482c503682588d Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 9 Jul 2026 21:47:43 +0800 Subject: [PATCH] fix(playground): keep model selector lists in sync - constrain the desktop selector panel so long group and model lists scroll within a shared height. - make the selected model stand out with stronger row and label styling. - scroll both lists to the active selections after the popover opens. --- .../components/model-group-selector-layout.ts | 78 ++++++++++++++ .../src/components/model-group-selector.tsx | 100 ++++++++++++++++-- 2 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 web/default/src/components/model-group-selector-layout.ts diff --git a/web/default/src/components/model-group-selector-layout.ts b/web/default/src/components/model-group-selector-layout.ts new file mode 100644 index 00000000..ecb7b6d3 --- /dev/null +++ b/web/default/src/components/model-group-selector-layout.ts @@ -0,0 +1,78 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +export const modelGroupSelectorLayoutClasses = { + desktopPanel: 'max-h-[min(50vh,28rem)] overflow-hidden', + desktopContent: + 'grid h-[min(50vh,28rem)] max-h-[min(50vh,28rem)] min-h-0 gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]', + groupColumn: 'flex h-full min-h-0 min-w-0 flex-col overflow-hidden', + groupScroll: 'mt-2 grid min-h-0 flex-1 gap-1 overflow-y-auto pr-1', + modelColumn: 'flex h-full min-h-0 min-w-0 overflow-hidden rounded-lg border', + modelCommand: 'min-h-0 flex-1 rounded-lg border-0 bg-transparent p-1', + modelList: + 'min-h-0 flex-1 max-h-none [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:block [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-track]:bg-transparent', + modelItem: + 'relative mb-0.5 flex items-center justify-between rounded-md border border-transparent px-2 py-1.5 pl-3 text-[12px] leading-4 transition-colors before:absolute before:inset-y-2 before:left-1 before:w-1 before:rounded-full before:bg-transparent', + selectedModelItem: + 'border-primary/40 bg-primary/12 text-foreground shadow-sm before:bg-primary', + unselectedModelItem: + 'text-muted-foreground hover:bg-accent hover:text-foreground', + selectedModelText: 'font-semibold text-foreground', + unselectedModelText: 'font-medium', +} as const + +type ScrollableOption = { + offsetHeight?: number + offsetTop?: number + scrollIntoView: (options?: ScrollIntoViewOptions) => void +} + +type ScrollableOptionContainer = { + clientHeight: number + scrollTo?: (options: ScrollToOptions) => void + scrollTop: number +} + +export function scrollSelectedOptionIntoView( + selectedOption: ScrollableOption | null, + scrollContainer?: ScrollableOptionContainer | null +): void { + if ( + scrollContainer && + selectedOption?.offsetTop !== undefined && + selectedOption.offsetHeight !== undefined + ) { + const scrollTop = Math.max( + 0, + selectedOption.offsetTop - + (scrollContainer.clientHeight - selectedOption.offsetHeight) / 2 + ) + if (scrollContainer.scrollTo) { + scrollContainer.scrollTo({ top: scrollTop, behavior: 'auto' }) + } else { + scrollContainer.scrollTop = scrollTop + } + return + } + + selectedOption?.scrollIntoView({ + block: 'center', + inline: 'nearest', + }) +} diff --git a/web/default/src/components/model-group-selector.tsx b/web/default/src/components/model-group-selector.tsx index 2c7d4ac2..a85f3aac 100644 --- a/web/default/src/components/model-group-selector.tsx +++ b/web/default/src/components/model-group-selector.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useMemo, useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -44,6 +44,11 @@ import { import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' +import { + modelGroupSelectorLayoutClasses, + scrollSelectedOptionIntoView, +} from './model-group-selector-layout' + interface ModelOption { label: string value: string @@ -565,6 +570,9 @@ export const ModelGroupSelector: React.FC = ({ const [open, setOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const isMobile = useIsMobile() + const groupScrollContainerRef = useRef(null) + const selectedGroupOptionRef = useRef(null) + const selectedModelOptionRef = useRef(null) const currentModel = useMemo( () => models.find((model) => model.value === selectedModel), @@ -610,6 +618,28 @@ export const ModelGroupSelector: React.FC = ({ [onGroupChange] ) + useEffect(() => { + if (!open) { + return + } + + let secondFrameId = 0 + const firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(() => { + scrollSelectedOptionIntoView( + selectedGroupOptionRef.current, + groupScrollContainerRef.current + ) + scrollSelectedOptionIntoView(selectedModelOptionRef.current) + }) + }) + + return () => { + window.cancelAnimationFrame(firstFrameId) + window.cancelAnimationFrame(secondFrameId) + } + }, [open, selectedGroup, selectedModel]) + const renderTrigger = () => (