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.
This commit is contained in:
QuentinHsu
2026-07-09 21:47:43 +08:00
parent a79f96919e
commit 4645ad9df5
2 changed files with 168 additions and 10 deletions
@@ -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 <https://www.gnu.org/licenses/>.
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',
})
}
+90 -10
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<ModelGroupSelectorProps> = ({
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const isMobile = useIsMobile()
const groupScrollContainerRef = useRef<HTMLDivElement | null>(null)
const selectedGroupOptionRef = useRef<HTMLButtonElement | null>(null)
const selectedModelOptionRef = useRef<HTMLDivElement | null>(null)
const currentModel = useMemo(
() => models.find((model) => model.value === selectedModel),
@@ -610,6 +618,28 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
[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 = () => (
<Button
aria-expanded={open}
@@ -636,11 +666,22 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
)
const renderGroupList = () => (
<div className='min-w-0 space-y-2'>
<div
className={cn(
'min-w-0 space-y-2',
!isMobile && modelGroupSelectorLayoutClasses.groupColumn
)}
>
<div className='text-muted-foreground px-1 text-[11px] leading-4 font-medium'>
{t('Model Group')}
</div>
<div className='grid gap-1'>
<div
className={cn(
'grid gap-1',
!isMobile && modelGroupSelectorLayoutClasses.groupScroll
)}
ref={groupScrollContainerRef}
>
{groups.map((group) => {
const isSelected = selectedGroup === group.value
@@ -655,6 +696,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
disabled={disabled}
key={group.value}
onClick={() => handleGroupChange(group.value)}
ref={isSelected ? selectedGroupOptionRef : undefined}
type='button'
>
<span className='min-w-0 truncate font-medium'>
@@ -675,7 +717,10 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
const renderModelList = () => (
<Command
className='min-w-0 rounded-lg border-0 bg-transparent p-1'
className={cn(
'min-w-0 rounded-lg border-0 bg-transparent p-1',
!isMobile && modelGroupSelectorLayoutClasses.modelCommand
)}
filter={() => 1}
shouldFilter={false}
>
@@ -685,7 +730,11 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
placeholder={t('Search models...')}
value={searchQuery}
/>
<CommandList className={isMobile ? 'max-h-[45vh]' : 'max-h-[20rem]'}>
<CommandList
className={
isMobile ? 'max-h-[45vh]' : modelGroupSelectorLayoutClasses.modelList
}
>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground px-3 py-8 text-center text-[12px] leading-5'>
{t('No model found.')}
@@ -694,12 +743,29 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
<CommandGroup className='p-1'>
{filteredModels.map((model) => (
<CommandItem
className='mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-[12px] leading-4 transition-colors'
className={cn(
modelGroupSelectorLayoutClasses.modelItem,
selectedModel === model.value
? modelGroupSelectorLayoutClasses.selectedModelItem
: modelGroupSelectorLayoutClasses.unselectedModelItem
)}
key={model.value}
onSelect={handleModelChange}
ref={
selectedModel === model.value
? selectedModelOptionRef
: undefined
}
value={model.value}
>
<span className='min-w-0 truncate font-medium'>
<span
className={cn(
'min-w-0 truncate',
selectedModel === model.value
? modelGroupSelectorLayoutClasses.selectedModelText
: modelGroupSelectorLayoutClasses.unselectedModelText
)}
>
{model.label}
</span>
<Check
@@ -717,9 +783,20 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
)
const renderContent = () => (
<div className='grid gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]'>
<div
className={
isMobile
? 'grid gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]'
: modelGroupSelectorLayoutClasses.desktopContent
}
>
{renderGroupList()}
<div className='min-w-0 overflow-hidden rounded-lg border'>
<div
className={cn(
'min-w-0 overflow-hidden rounded-lg border',
!isMobile && modelGroupSelectorLayoutClasses.modelColumn
)}
>
{renderModelList()}
</div>
</div>
@@ -742,7 +819,10 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
<PopoverTrigger render={renderTrigger()} />
<PopoverContent
align='end'
className='bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg'
className={cn(
'bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg',
modelGroupSelectorLayoutClasses.desktopPanel
)}
collisionPadding={8}
side='top'
sideOffset={8}