diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx index b307e3d0f2a..b72ebdc9c07 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -135,6 +135,7 @@ const keyEntry = ( api_key_id, max_turns: 200, stopped_at: null, + attempt_count: null, key_alias: null, key_name: null, ...overrides, @@ -359,15 +360,19 @@ describe("ShadowEvalSection", () => { expect(container).toBeEmptyDOMElement(); }); - it("keeps the start button disabled until key, router, and judge model are picked, then submits the key as a list", async () => { + it("keeps the start button disabled until key, router, and judge model are picked, then submits every picked key", async () => { const user = userEvent.setup(); const { start } = mockHooks({}); render(); expect(screen.getByText("Start shadow eval")).toBeDisabled(); - await user.click(screen.getByPlaceholderText("Search keys by alias")); - await user.click(await screen.findByText("prod-alpha")); + const keyInput = screen.getByPlaceholderText("Search keys by alias"); + await user.click(keyInput); + const keyList = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(keyList).getByText("prod-alpha")); + await user.click(keyInput); + await user.click(within(keyList).getByText("staging-beta")); await user.click(screen.getByPlaceholderText("Select an auto-router")); await user.click(await screen.findByText("gpt-auto")); @@ -378,7 +383,7 @@ describe("ShadowEvalSection", () => { await user.click(screen.getByText("Start shadow eval")); const expectedBody = { - api_key_ids: ["hash-alpha"], + api_key_ids: ["hash-alpha", "hash-beta"], router_name: "gpt-auto", direction: "forward", shadow_percentage: 10, @@ -399,7 +404,8 @@ describe("ShadowEvalSection", () => { await user.click(screen.getByText("Adoption check: key's traffic vs the router")); await user.click(await screen.findByText("Regression check: router's picks vs a baseline")); await user.click(screen.getByPlaceholderText("Search keys by alias")); - await user.click(await screen.findByText("prod-alpha")); + const keyList = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(keyList).getByText("prod-alpha")); await user.click(screen.getByPlaceholderText("Select an auto-router")); await user.click(await screen.findByText("gpt-auto")); await user.click(screen.getByPlaceholderText("Select a judge model")); @@ -451,6 +457,119 @@ describe("ShadowEvalSection", () => { expect(shadowedKeyLabel(keyEntry("hashed-key-abc"))).toBe("hashed-key…"); }); + it("breaks results down per key, so one key exhausting its own budget is visible while a sibling runs on", () => { + mockHooks({ + jobs: [ + job({ + judged_count: 205, + keys: [ + keyEntry("hash-spent", { max_turns: 200, stopped_at: "2026-08-08T00:00:00Z" }), + keyEntry("hash-hungry", { max_turns: 500 }), + ], + results: { + by_tier: [], + by_current_model: [], + by_key: [ + { + group: "hash-spent", + turn_count: 200, + real_win_rate_pct: 20.0, + shadow_win_rate_pct: 60.0, + tie_rate_pct: 20.0, + avg_judge_confidence: 0.9, + }, + ], + overall_shadow_win_rate_pct: 60.0, + overall_tie_rate_pct: 20.0, + }, + }), + ], + }); + render(); + + const spent = screen.getByText("hash-spent…").closest("tr"); + const hungry = screen.getByText("hash-hungr…").closest("tr"); + if (!spent || !hungry) throw new Error("expected a table row per scoped key"); + + expect(within(spent).getByText("stopped")).toBeInTheDocument(); + expect(within(spent).getByText("200 / 200")).toBeInTheDocument(); + expect(within(spent).getByText("60.0%")).toBeInTheDocument(); + + expect(within(hungry).getByText("running")).toBeInTheDocument(); + expect(within(hungry).getByText("0 / 500")).toBeInTheDocument(); + expect(within(hungry).getByText("No verdicts yet")).toBeInTheDocument(); + + expect(screen.getByText(/205 of 700 turns judged/)).toBeInTheDocument(); + expect(screen.getByText(/Shadowing 10% of/)).toBeInTheDocument(); + expect(screen.getByText("2 keys")).toBeInTheDocument(); + }); + + it("reads a key that spent its budget as completed even before the sweep stamps it", () => { + mockHooks({ + jobs: [ + job({ + keys: [ + keyEntry("hash-spent", { max_turns: 200, attempt_count: 200 }), + keyEntry("hash-hungry", { max_turns: 500, attempt_count: 3 }), + ], + }), + ], + }); + render(); + + const spent = screen.getByText("hash-spent…").closest("tr"); + const hungry = screen.getByText("hash-hungr…").closest("tr"); + if (!spent || !hungry) throw new Error("expected a table row per scoped key"); + expect(within(spent).getByText("completed")).toBeInTheDocument(); + expect(within(spent).getByText("200 / 200")).toBeInTheDocument(); + expect(within(hungry).getByText("running")).toBeInTheDocument(); + expect(within(hungry).getByText("3 / 500")).toBeInTheDocument(); + }); + + it("shows the per-key table while a multi-key job is still collecting, before any verdicts exist", () => { + mockHooks({ + jobs: [ + job({ + judged_count: 0, + results: null, + keys: [ + keyEntry("hash-spent", { max_turns: 2, attempt_count: 2 }), + keyEntry("hash-hungry", { max_turns: 500, attempt_count: 1 }), + ], + }), + ], + }); + render(); + + const spent = screen.getByText("hash-spent…").closest("tr"); + if (!spent) throw new Error("expected a per-key row before verdicts exist"); + expect(within(spent).getByText("completed")).toBeInTheDocument(); + expect(within(spent).getByText("2 / 2")).toBeInTheDocument(); + expect(screen.getByText("Budget used")).toBeInTheDocument(); + expect(screen.queryByText("Judged turns")).not.toBeInTheDocument(); + expect(screen.getByText(/Collecting verdicts/)).toBeInTheDocument(); + }); + + it("reads every key as completed once the job's window closes, whatever its own stop state", () => { + mockHooks({ + jobs: [ + job({ + status: "completed", + keys: [ + keyEntry("hash-spent", { max_turns: 200, stopped_at: "2026-08-08T00:00:00Z" }), + keyEntry("hash-hungry", { max_turns: 500 }), + ], + }), + ], + }); + render(); + + const hungry = screen.getByText("hash-hungr…").closest("tr"); + if (!hungry) throw new Error("expected a table row per scoped key"); + expect(within(hungry).getByText("completed")).toBeInTheDocument(); + expect(within(hungry).queryByText("running")).not.toBeInTheDocument(); + }); + it("keeps an older job's verdicts reachable through the previous evaluations list", async () => { const user = userEvent.setup(); const emptyOverrides: Partial = { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx index b7b512cba0d..bc5044feaa6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx @@ -6,7 +6,7 @@ import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useAutoRouters, usePlainModelGroups } from "@/app/(dashboard)/hooks/models/useModels"; -import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; +import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -59,6 +59,13 @@ const shadowedKeysLabel = (job: ShadowEvalJob): string => const totalBudget = (job: ShadowEvalJob): number => job.keys.reduce((sum, key) => sum + key.max_turns, 0); +const keySpent = (key: ShadowEvalJobKey): boolean => key.attempt_count != null && key.attempt_count >= key.max_turns; + +const keyStatus = (job: ShadowEvalJob, key: ShadowEvalJobKey): string => { + if (job.status === "completed" || (key.stopped_at == null && keySpent(key))) return "completed"; + return key.stopped_at != null ? "stopped" : "running"; +}; + const jobHeadline = (job: ShadowEvalJob): React.ReactNode => job.direction === "reverse" ? ( <> @@ -175,6 +182,55 @@ const VerdictBar: React.FC<{ direction: ShadowEvalDirection; results: NonNullabl ); }; +const KeyTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => { + const slices = new Map((job.results?.by_key ?? []).map((slice) => [slice.group, slice])); + return ( + + + + Key + Status + {["Budget used", "Router wins", `${otherArmLabel(job.direction)} wins`].map((label) => ( + + {label} + + ))} + + + + {job.keys.map((key) => { + const slice = slices.get(key.api_key_id); + return ( + + {shadowedKeyLabel(key)} + + + + + {(key.attempt_count ?? slice?.turn_count ?? 0).toLocaleString()} / {key.max_turns.toLocaleString()} + + {slice ? ( + <> + + {pct(routerWinRate(job.direction, slice))} + + + {pct(otherArmWinRate(job.direction, slice))} + + + ) : ( + + No verdicts yet + + )} + + ); + })} + +
+ ); +}; + const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string => { if (resultsError) return "Results could not be loaded. Retrying."; if (isActive(job)) return "Collecting verdicts. Results appear as sampled requests are judged."; @@ -184,32 +240,45 @@ const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string => const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({ job, resultsError = false }) => { const results = job.results; - const stratifications = results ? [results.by_tier, results.by_current_model, results.by_key] : []; - if (!results || stratifications.every((slices) => slices.length === 0)) { - return

{emptyResultsText(job, resultsError)}

; - } + const hasVerdicts = results != null && (results.by_tier.length > 0 || results.by_current_model.length > 0); return ( <> -
-

- Router matched or beat {job.direction === "reverse" ? "the baseline" : "your current model"} -

-

{pct(routerMatchedOrBeatPct(job.direction, results))}

-

of {(job.judged_count ?? 0).toLocaleString()} judged responses

-
- - {results.by_current_model.length > 0 && ( - - )} - {results.by_tier.length > 0 && ( -
0 ? "border-t" : ""}> - + {job.keys.length > 1 && ( +
+
)} + {/* results == null re-stated for TS narrowing; hasVerdicts alone cannot narrow it */} + {!hasVerdicts || results == null ? ( +

{emptyResultsText(job, resultsError)}

+ ) : ( + <> +
+

+ Router matched or beat {job.direction === "reverse" ? "the baseline" : "your current model"} +

+

+ {pct(routerMatchedOrBeatPct(job.direction, results))} +

+

+ of {(job.judged_count ?? 0).toLocaleString()} judged responses +

+
+ + {results.by_current_model.length > 0 && ( + + )} + {results.by_tier.length > 0 && ( +
0 ? "border-t" : ""}> + +
+ )} + + )} ); }; @@ -306,9 +375,9 @@ const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[ const START_FORM_DESCRIPTION: Record = { forward: - "Duplicates a sampled slice of the key's traffic through the auto-router and has an LLM judge compare both answers blind. The router's answers are never served to users; judge calls bill to the shadowed key.", + "Duplicates a sampled slice of the selected keys' traffic through the auto-router and has an LLM judge compare both answers blind. Each key gets its own turn budget. The router's answers are never served to users; judge calls bill to the shadowed key.", reverse: - "Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. The baseline's answers are never served to users; judge calls bill to the shadowed key.", + "Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each key gets its own turn budget. The baseline's answers are never served to users; judge calls bill to the shadowed key.", }; const DURATION_OPTIONS = [ @@ -333,7 +402,7 @@ const Field: React.FC<{ label: string; htmlFor?: string; className?: string; chi
); -const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }> = ({ value, onChange }) => { +const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => void }> = ({ value, onChange }) => { const [search, setSearch] = useState(""); const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, { selectedKeyAlias: search || null, @@ -350,7 +419,7 @@ const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }> [data], ); return ( - void }> const StartForm: React.FC = () => { const { accessToken } = useAuthorized(); - const [apiKeyId, setApiKeyId] = useState(""); + const [apiKeyIds, setApiKeyIds] = useState([]); const [routerName, setRouterName] = useState(""); const [direction, setDirection] = useState("forward"); const [baselineModel, setBaselineModel] = useState(""); @@ -394,12 +463,12 @@ const StartForm: React.FC = () => { const parsedMaxTurns = Number.parseInt(maxTurns, 10); const maxTurnsValid = parsedMaxTurns >= 1 && parsedMaxTurns <= 2000; const baselinePicked = direction === "forward" || baselineModel !== ""; - const filled = [apiKeyId, routerName, judgeModel].every((field) => field !== "") && baselinePicked; + const filled = apiKeyIds.length > 0 && [routerName, judgeModel].every((field) => field !== "") && baselinePicked; const boundsValid = percentageValid && maxTurnsValid; const valid = Boolean(accessToken) && filled && boundsValid; const handleStart = () => { const startBody = { - api_key_ids: [apiKeyId], + api_key_ids: apiKeyIds, router_name: routerName, direction, ...(direction === "reverse" ? { baseline_model: baselineModel } : {}), @@ -436,8 +505,8 @@ const StartForm: React.FC = () => { - - + + > = {}) { + const props: React.ComponentProps = { + options: OPTIONS, + onValueChange: vi.fn(), + onSearchChange: vi.fn(), + onLoadMore: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) { + Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true }); + Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true }); +} + +function chipRemoveButton(label: string): HTMLElement { + const chip = screen.getByText(label).closest('[data-slot="combobox-chip"]'); + if (chip === null) throw new Error(`no chip found for ${label}`); + const button = chip.querySelector('[data-slot="combobox-chip-remove"]'); + if (button === null) throw new Error(`no remove control found on chip for ${label}`); + return button as HTMLElement; +} + +describe("PaginatedMultiSelect", () => { + it("reports the cleared query upstream after a selection, so the next open is not still filtered", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + + function Controlled() { + const [value, setValue] = useState([]); + return ( + + ); + } + render(); + + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "alias-a"); + await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("alias-a"), { timeout: 2000 }); + + const list = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(list).getByText("alias-alpha")); + + expect(input).toHaveValue(""); + await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 }); + }); + + it("selects multiple values and reports them cumulatively", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Controlled() { + const [value, setValue] = useState([]); + return ( + { + setValue(next); + onValueChange(next); + }} + onSearchChange={vi.fn()} + onLoadMore={vi.fn()} + /> + ); + } + render(); + + const input = screen.getByRole("combobox"); + await user.click(input); + const list = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(list).getByText("alias-alpha")); + + await user.click(input); + await user.click(within(list).getByText("gamma-key")); + + expect(onValueChange).toHaveBeenLastCalledWith(["alias-alpha", "gamma-key"]); + const chips = document.querySelector('[data-slot="combobox-chips"]') as HTMLElement; + expect(within(chips).getByText("alias-alpha")).toBeInTheDocument(); + expect(within(chips).getByText("gamma-key")).toBeInTheDocument(); + }); + + it("deselects one value via the chip remove control and keeps the rest", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Controlled() { + const [value, setValue] = useState(["alias-alpha", "alias-beta"]); + return ( + { + setValue(next); + onValueChange(next); + }} + onSearchChange={vi.fn()} + onLoadMore={vi.fn()} + /> + ); + } + render(); + + await user.click(chipRemoveButton("alias-alpha")); + + expect(onValueChange).toHaveBeenCalledWith(["alias-beta"]); + expect(screen.queryByText("alias-alpha")).not.toBeInTheDocument(); + expect(screen.getByText("alias-beta")).toBeInTheDocument(); + }); + + it("keeps a selected chip visible after the options page no longer contains it", () => { + const { rerender } = render( + , + ); + + expect(screen.getByText("ghost-key")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("ghost-key")).toBeInTheDocument(); + }); + + it("keeps a picked chip's label after the search filters it off the options page", async () => { + const user = userEvent.setup(); + + const aliased: SearchSelectOption[] = [ + { label: "Prod Alpha", value: "hash-alpha" }, + { label: "Staging Beta", value: "hash-beta" }, + ]; + + function Controlled({ options }: { options: SearchSelectOption[] }) { + const [value, setValue] = useState([]); + return ( + + ); + } + const { rerender } = render(); + + const input = screen.getByRole("combobox"); + await user.click(input); + const list = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(list).getByText("Prod Alpha")); + + rerender(); + + const chips = document.querySelector('[data-slot="combobox-chips"]') as HTMLElement; + expect(within(chips).getByText("Prod Alpha")).toBeInTheDocument(); + expect(within(chips).queryByText("hash-alpha")).not.toBeInTheDocument(); + }); + + it("anchors the dropdown to the chips container so it tracks the growing chip box", async () => { + const user = userEvent.setup(); + renderSelect({}); + + await user.click(screen.getByRole("combobox")); + await screen.findByTestId("paginated-multi-select-list"); + + const content = document.querySelector('[data-slot="combobox-content"]'); + expect(content).toHaveAttribute("data-chips", "true"); + }); + + it("does not request the next page on scroll when there is no next page", async () => { + const user = userEvent.setup(); + const onLoadMore = vi.fn(); + renderSelect({ onLoadMore, hasNextPage: false }); + + const input = screen.getByRole("combobox"); + await user.click(input); + const list = await screen.findByTestId("paginated-multi-select-list"); + + setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it("requests the next page once scrolled past the threshold when a next page exists", async () => { + const user = userEvent.setup(); + const onLoadMore = vi.fn(); + renderSelect({ onLoadMore, hasNextPage: true }); + + const input = screen.getByRole("combobox"); + await user.click(input); + const list = await screen.findByTestId("paginated-multi-select-list"); + + setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).not.toHaveBeenCalled(); + + setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx new file mode 100644 index 00000000000..233c527d1a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; + +import type { SearchSelectOption } from "./SearchSelect"; +import { usePaginatedCombobox } from "./usePaginatedCombobox"; + +interface PaginatedMultiSelectProps { + options: SearchSelectOption[]; + value?: string[]; + onValueChange: (value: string[]) => void; + onSearchChange: (query: string) => void; + onLoadMore: () => void; + hasNextPage?: boolean; + isLoading?: boolean; + isFetchingNextPage?: boolean; + placeholder?: string; + emptyText?: string; + errorText?: string; + loadingText?: string; + disabled?: boolean; + className?: string; + inputId?: string; + "aria-invalid"?: true | undefined; + "aria-describedby"?: string; +} + +export function PaginatedMultiSelect({ + options, + value = [], + onValueChange, + onSearchChange, + onLoadMore, + hasNextPage = false, + isLoading = false, + isFetchingNextPage = false, + placeholder = "Search…", + emptyText = "No results", + errorText, + loadingText = "Loading…", + disabled = false, + className, + inputId, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, +}: PaginatedMultiSelectProps) { + const anchor = useComboboxAnchor(); + const [query, setQuery] = useState(""); + const [pickedOptions, setPickedOptions] = useState>(new Map()); + + const selected = useMemo( + () => + value.map( + (selectedValue) => + options.find((option) => option.value === selectedValue) ?? + pickedOptions.get(selectedValue) ?? { label: selectedValue, value: selectedValue }, + ), + [options, value, pickedOptions], + ); + + const items = useMemo(() => { + const missing = selected.filter((option) => !options.some((o) => o.value === option.value)); + return missing.length === 0 ? options : [...missing, ...options]; + }, [options, selected]); + + const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage }; + const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination); + + const handleChipsInputChange = (next: string, reason: string) => { + setQuery(next); + handleInputValueChange(next, reason); + }; + + return ( + { + setPickedOptions(new Map(next.map((option) => [option.value, option]))); + onValueChange(next.map((option) => option.value)); + }} + inputValue={query} + onInputValueChange={(next, eventDetails) => handleChipsInputChange(next, eventDetails.reason)} + isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} + itemToStringLabel={(item: SearchSelectOption) => item.label} + filter={null} + disabled={disabled} + > + } className={`min-h-8 py-1 text-sm ${className ?? ""}`}> + + {(selectedItems: SearchSelectOption[]) => + selectedItems.map((option) => ( + + {option.label} + + )) + } + + + + + + {errorText ?? (isLoading ? loadingText : emptyText)} + + + {(item: SearchSelectOption) => ( + + + {item.label} + {item.sublabel != null && item.sublabel !== "" && ( + {item.sublabel} + )} + + + )} + + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx index 14a3280dfc3..6966c669187 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -1,8 +1,7 @@ "use client"; -import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { Loader2 } from "lucide-react"; -import { useMemo, type UIEvent } from "react"; +import { useMemo } from "react"; import { Combobox, @@ -12,13 +11,9 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import type { SearchSelectOption } from "./SearchSelect"; - -const SCROLL_THRESHOLD = 0.8; - -const SEARCH_REASONS: ReadonlySet = new Set(["input-change", "input-clear", "clear-press"]); +import { usePaginatedCombobox } from "./usePaginatedCombobox"; interface PaginatedSearchSelectProps { options: SearchSelectOption[]; @@ -70,21 +65,8 @@ export function PaginatedSearchSelect({ return [selected, ...options]; }, [options, selected]); - const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS }); - - const handleInputValueChange = (next: string, reason: string) => { - if (!SEARCH_REASONS.has(reason)) return; - debouncedSearch(next); - }; - - const handleScroll = (event: UIEvent) => { - const target = event.currentTarget; - if (target.scrollHeight === 0) return; - const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight; - if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { - onLoadMore?.(); - } - }; + const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage }; + const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination); return ( = new Set(["input-change", "input-clear", "clear-press"]); + +export interface PaginatedComboboxCallbacks { + onSearchChange: (query: string) => void; + onLoadMore?: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; +} + +export function usePaginatedCombobox({ + onSearchChange, + onLoadMore, + hasNextPage, + isFetchingNextPage, +}: PaginatedComboboxCallbacks) { + const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS }); + + const handleInputValueChange = (next: string, reason: string) => { + if (!SEARCH_REASONS.has(reason)) return; + debouncedSearch(next); + }; + + const handleScroll = (event: UIEvent) => { + const target = event.currentTarget; + if (target.scrollHeight === 0) return; + const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + onLoadMore?.(); + } + }; + + return { handleInputValueChange, handleScroll }; +}