diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 064df338e46..b75413fb3ea 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -32,6 +32,7 @@ export const ModelListRow = memo(function ModelListRow(props: { useTriggerLabel?: boolean; showNewBadge?: boolean; jumpLabel?: string | null; + isLast?: boolean; onToggleFavorite: () => void; }) { const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; @@ -47,7 +48,8 @@ export const ModelListRow = memo(function ModelListRow(props: { contentClassName="flex w-full items-start gap-2" className={cn( "w-full cursor-pointer rounded px-3 py-2 transition-colors group", - "data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground", + "border-b data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground", + props.isLast && "border-b-0", )} > diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c9fbd2b0fc1..43a37fb1cda 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -4,13 +4,20 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; -import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxListVirtualized, +} from "../ui/combobox"; import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { modelPickerJumpCommandForIndex, @@ -37,6 +44,10 @@ type ModelPickerItem = { }; const EMPTY_MODEL_JUMP_LABELS = new Map(); +const MODEL_PICKER_VIRTUALIZATION_THRESHOLD = 60; +const MODEL_PICKER_ESTIMATED_ROW_HEIGHT = 53; +const MODEL_PICKER_LIST_MAX_HEIGHT = 335; +const MODEL_PICKER_LIST_MAX_HEIGHT_NO_SIDEBAR = 289; // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the @@ -92,6 +103,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const listRegionRef = useRef(null); + const modelListRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); const [selectedInstanceId, setSelectedInstanceId] = useState( @@ -220,6 +232,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); + const modelPickerListMaxHeight = showSidebar + ? MODEL_PICKER_LIST_MAX_HEIGHT + : MODEL_PICKER_LIST_MAX_HEIGHT_NO_SIDEBAR; const sidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries : instanceEntries; @@ -418,6 +433,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), [filteredModels], ); + const shouldVirtualizeModelList = + filteredModelKeys.length > MODEL_PICKER_VIRTUALIZATION_THRESHOLD; const modelJumpShortcutContext = useMemo( () => ({ @@ -478,6 +495,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); useLayoutEffect(() => { + if (shouldVirtualizeModelList) { + return; + } + const listRegion = listRegionRef.current; if (!listRegion) { return; @@ -518,7 +539,43 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { window.cancelAnimationFrame(nestedFrame); window.clearTimeout(timeout); }; - }, [filteredModelKeys]); + }, [filteredModelKeys, shouldVirtualizeModelList]); + + const renderModelRow = useCallback( + (modelKey: string, index: number) => { + const model = filteredModelByKey.get(modelKey); + if (!model) { + return null; + } + return ( + toggleFavorite(model.instanceId, model.slug)} + /> + ); + }, + [ + favoritesSet, + filteredModelByKey, + isLocked, + modelJumpLabelByKey, + showLockedInstanceSidebar, + toggleFavorite, + ], + ); return ( @@ -555,9 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { filter={null} autoHighlight open + virtualized={shouldVirtualizeModelList} value={`${props.activeInstanceId}:${props.model}`} - onItemHighlighted={(modelKey) => { + onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; + if ( + shouldVirtualizeModelList && + eventDetails.index >= 0 && + eventDetails.reason === "keyboard" + ) { + modelListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); + } }} onValueChange={(modelKey) => { if (typeof modelKey !== "string") { @@ -616,32 +684,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={listRegionRef} className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40" > - - {filteredModelKeys.map((modelKey, index) => { - const model = filteredModelByKey.get(modelKey); - if (!model) { - return null; - } - return ( - toggleFavorite(model.instanceId, model.slug)} - /> - ); - })} - + {shouldVirtualizeModelList ? ( + + + ref={modelListRef} + data={filteredModelKeys} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderModelRow(item, index)} + estimatedItemSize={MODEL_PICKER_ESTIMATED_ROW_HEIGHT} + drawDistance={modelPickerListMaxHeight} + style={{ maxHeight: modelPickerListMaxHeight }} + /> + + ) : ( + + {filteredModelKeys.map((modelKey, index) => renderModelRow(modelKey, index))} + + )} No models found diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 121b5267a3d..ca34da76125 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -80,7 +80,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index a2ed525035d..7439c528789 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -61,6 +61,7 @@ function PopoverPopup({ tooltipStyle ? "py-1 [--viewport-inline-padding:--spacing(2)]" : "not-data-transitioning:overflow-y-auto", + 'rounded-b-[inherit]' )} data-slot="popover-viewport" >