From 8d8834780012e2daf46c1a3a1d3790c54748a112 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 13:51:45 +0000 Subject: [PATCH] feat(ui): PaginatedModelSelect to shadcn combobox - antd Select with onPopupScroll + LoadingOutlined \u2192 shadcn Popover + Button (combobox) + Input + lucide Loader2/ChevronsUpDown/X. - Each option renders as a two-line label (model name + ID) via a simple custom layout, same contract as the original optionRender. - Infinite-scroll threshold preserved at 0.8 on the list container. Gates: TS \u2713 | Build \u2713. Co-authored-by: yuneng-jiang --- .../PaginatedModelSelect.tsx | 193 ++++++++++++------ 1 file changed, 126 insertions(+), 67 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx index 9b22fd1bd87..5cf533a81b5 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx @@ -1,10 +1,15 @@ import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; -import { LoadingOutlined } from "@ant-design/icons"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; -import { Select, Space, Typography } from "antd"; -import { useMemo, useState, type UIEvent } from "react"; - -const { Text } = Typography; +import { ChevronsUpDown, Loader2, X } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; export interface PaginatedModelSelectProps { value?: string; @@ -28,31 +33,32 @@ export const PaginatedModelSelect = ({ allowClear = true, disabled = false, }: PaginatedModelSelectProps) => { + const [open, setOpen] = useState(false); const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { wait: DEBOUNCE_MS, }); + const listRef = useRef(null); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useInfiniteModelInfo(pageSize, debouncedSearch || undefined); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteModelInfo(pageSize, debouncedSearch || undefined); const options = useMemo(() => { if (!data?.pages) return []; const seen = new Set(); - const result: { label: string; value: string; modelName: string; modelId: string }[] = []; + const result: { + label: string; + value: string; + modelName: string; + modelId: string; + }[] = []; for (const page of data.pages) { for (const model of page.data) { const modelId = model.model_info?.id ?? ""; const modelName = model.model_name ?? ""; - // Dedupe by id - skip models without id (can't uniquely identify) if (!modelId || seen.has(modelId)) continue; seen.add(modelId); @@ -68,29 +74,9 @@ export const PaginatedModelSelect = ({ return result; }, [data]); - const optionRender = (option: { data: { modelName: string; modelId: string; label: string } }) => { - const { modelName, modelId } = option.data; + const selected = value ? options.find((o) => o.value === value) : undefined; - return ( - <> - {modelName ? ( - - - Model name: - {modelName} - - - Model ID: {modelId} - - - ) : ( - Model ID: {modelId} - )} - - ); - }; - - const handlePopupScroll = (e: UIEvent) => { + const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; @@ -100,44 +86,117 @@ export const PaginatedModelSelect = ({ } }; - const handleSearch = (value: string) => { - setSearchInput(value); - setDebouncedSearch(value); + const handleSearch = (v: string) => { + setSearchInput(v); + setDebouncedSearch(v); }; - const handleChange = (v: string | string[] | null) => { - const normalized = - typeof v === "string" ? v : Array.isArray(v) ? v[0] ?? "" : ""; - onChange?.(normalized); + const select = (id: string) => { + onChange?.(id); + setOpen(false); + }; + + const clear = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(""); }; return ( - handleSearch(e.target.value)} + className="h-8" + /> + +
+ {isLoading && options.length === 0 ? ( +
+ +
+ ) : options.length === 0 ? ( +
+ No models found +
+ ) : ( + options.map((option) => ( + + )) + )} {isFetchingNextPage && ( -
- +
+
)} - - )} - /> +
+ + ); };