From c5c7061c3ecd6d0b8a02cf20e871ebcaf320257b Mon Sep 17 00:00:00 2001 From: gabriele Date: Mon, 5 Oct 2026 10:26:36 +0000 Subject: [PATCH 1/2] fix(ui): cap rendered MultiSelect options and stop matching descriptions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/shared/MultiSelect.test.tsx | 60 ++++++++++++++++++- .../src/components/shared/MultiSelect.tsx | 33 +++++++--- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx index cc4084a952e..610c7e225ff 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -1,13 +1,19 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { MultiSelect, type MultiSelectOption } from "./MultiSelect"; +import { MAX_VISIBLE_OPTIONS, MultiSelect, type MultiSelectOption } from "./MultiSelect"; const OPTIONS: MultiSelectOption[] = [ { value: "vs-alpha", label: "alpha-kb (vs-alpha)" }, { value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" }, ]; +const LARGE_OPTIONS: MultiSelectOption[] = Array.from({ length: 5000 }, (_, index) => ({ + value: `tag-${index}`, + label: `tag-${index}`, + description: "shared spend tag description", +})); + const renderMultiSelect = (props: Partial> = {}) => { const onValueChange = vi.fn(); render(); @@ -69,6 +75,58 @@ describe("MultiSelect", () => { expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument(); }); + it("caps rendered options and reports the number of matching options", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS }); + + await openPopup(input); + + expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS); + expect(screen.getByText("Showing first 100 of 5000 matches. Type to narrow results.")).toBeInTheDocument(); + }); + + it("does not match the option description", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS }); + await userEvent.type(input, "shared spend"); + + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(await screen.findByText("No options found")).toBeInTheDocument(); + }); + + it("filters by label and updates the match hint", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS }); + await userEvent.type(input, "tag-49"); + + expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS); + expect(screen.getByText("Showing first 100 of 111 matches. Type to narrow results.")).toBeInTheDocument(); + }); + + it("shows an under-cap label match without the match hint", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS }); + await userEvent.type(input, "tag-4999"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /tag-4999/ })).toBeInTheDocument(); + expect(screen.queryByText(/Showing first/)).not.toBeInTheDocument(); + }); + + it("keeps the custom option available when matching results exceed the cap", async () => { + const { onValueChange, input } = renderMultiSelect({ options: LARGE_OPTIONS, allowCustomValues: true }); + await userEvent.type(input, "tag"); + + await userEvent.click(screen.getByRole("option", { name: 'Create "tag"' })); + + expect(onValueChange).toHaveBeenCalledWith(["tag"]); + }); + + it("renders a selected chip outside the visible option slice", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS, value: ["tag-4999"] }); + + await openPopup(input); + + expect(screen.getByLabelText("tag-4999")).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS); + }); + it("labels an unknown selected value with its raw id", () => { renderMultiSelect({ value: ["vs-deleted"] }); diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index 3a153ff2a5f..aed8ccec591 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Combobox, ComboboxChip, @@ -15,6 +15,8 @@ import { useComboboxAnchor, } from "@/components/ui/combobox"; +export const MAX_VISIBLE_OPTIONS = 100; + export interface MultiSelectOption { label: string; value: string; @@ -46,8 +48,7 @@ const matchesQuery = (option: MultiSelectOption, query: string): boolean => { return ( !normalizedQuery || option.label.toLowerCase().includes(normalizedQuery) || - option.value.toLowerCase().includes(normalizedQuery) || - (option.description?.toLowerCase().includes(normalizedQuery) ?? false) + option.value.toLowerCase().includes(normalizedQuery) ); }; @@ -65,10 +66,19 @@ export function MultiSelect({ }: MultiSelectProps) { const anchor = useComboboxAnchor(); const [query, setQuery] = useState(""); - const safeOptions = options.filter( - (option): option is MultiSelectOption => - option != null && typeof option.value === "string" && option.value.length > 0, + const safeOptions = useMemo( + () => + options.filter( + (option): option is MultiSelectOption => + option != null && typeof option.value === "string" && option.value.length > 0, + ), + [options], ); + const matchingOptions = useMemo( + () => safeOptions.filter((option) => matchesQuery(option, query)), + [safeOptions, query], + ); + const visibleOptions = matchingOptions.slice(0, MAX_VISIBLE_OPTIONS); const selectedOptions = value .filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0) .map( @@ -82,8 +92,8 @@ export function MultiSelect({ const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase()); const items = allowCustomValues && customOption && !customOptionExists - ? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }] - : safeOptions; + ? [{ label: `Create "${customOption}"`, value: customOption }, ...visibleOptions] + : visibleOptions; const canClear = (selected: MultiSelectOption[]) => selected.length > 0 && !disabled && !loading; @@ -99,13 +109,13 @@ export function MultiSelect({ option.value === selected.value} itemToStringLabel={(option: MultiSelectOption) => option.label} - filter={matchesQuery} disabled={disabled || loading} > } className={`min-h-8 py-1 text-sm ${className ?? ""}`}> @@ -142,6 +152,11 @@ export function MultiSelect({ )} + {matchingOptions.length > MAX_VISIBLE_OPTIONS && ( +
+ Showing first {MAX_VISIBLE_OPTIONS} of {matchingOptions.length} matches. Type to narrow results. +
+ )}
); From f60bee0da36260351209b518abddd8b0f28d5138 Mon Sep 17 00:00:00 2001 From: gabriele Date: Mon, 5 Oct 2026 10:53:56 +0000 Subject: [PATCH 2/2] fix(ui): keep MultiSelect description search opt-out and disable it for tags Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../add_model/advanced_settings.tsx | 1 + .../components/shared/MultiSelect.test.tsx | 12 ++++- .../src/components/shared/MultiSelect.tsx | 18 +++---- .../tag_management/TagSelector.test.tsx | 48 ++++++++++++++++++- .../components/tag_management/TagSelector.tsx | 1 + 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index eb12e49ca89..1ad2d132135 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -193,6 +193,7 @@ const AdvancedSettings: React.FC = ({ label: tag.name, description: tag.description || undefined, }))} + searchDescriptions={false} allowCustomValues /> )} diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx index 610c7e225ff..7d411b7c006 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -84,8 +84,16 @@ describe("MultiSelect", () => { expect(screen.getByText("Showing first 100 of 5000 matches. Type to narrow results.")).toBeInTheDocument(); }); - it("does not match the option description", async () => { - const { input } = renderMultiSelect({ options: LARGE_OPTIONS }); + it("matches option descriptions by default", async () => { + const { input } = renderMultiSelect(); + await userEvent.type(input, "second store"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /beta-kb/ })).toBeInTheDocument(); + }); + + it("skips descriptions when searchDescriptions is false", async () => { + const { input } = renderMultiSelect({ options: LARGE_OPTIONS, searchDescriptions: false }); await userEvent.type(input, "shared spend"); expect(screen.queryAllByRole("option")).toHaveLength(0); diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index aed8ccec591..e7c85615324 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -34,6 +34,7 @@ interface MultiSelectProps { disabled?: boolean; loading?: boolean; allowCustomValues?: boolean; + searchDescriptions?: boolean; className?: string; } @@ -43,13 +44,13 @@ const splitOnCommas = (raw: string): string[] => .map((part) => part.trim()) .filter((part) => part.length > 0); -const matchesQuery = (option: MultiSelectOption, query: string): boolean => { +const matchesQuery = (option: MultiSelectOption, query: string, searchDescriptions: boolean): boolean => { const normalizedQuery = query.trim().toLowerCase(); - return ( - !normalizedQuery || - option.label.toLowerCase().includes(normalizedQuery) || - option.value.toLowerCase().includes(normalizedQuery) - ); + const matchesLabelOrValue = + option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery); + const matchesDescription = + searchDescriptions && (option.description?.toLowerCase().includes(normalizedQuery) ?? false); + return !normalizedQuery || matchesLabelOrValue || matchesDescription; }; export function MultiSelect({ @@ -62,6 +63,7 @@ export function MultiSelect({ disabled = false, loading = false, allowCustomValues = false, + searchDescriptions = true, className, }: MultiSelectProps) { const anchor = useComboboxAnchor(); @@ -75,8 +77,8 @@ export function MultiSelect({ [options], ); const matchingOptions = useMemo( - () => safeOptions.filter((option) => matchesQuery(option, query)), - [safeOptions, query], + () => safeOptions.filter((option) => matchesQuery(option, query, searchDescriptions)), + [safeOptions, query, searchDescriptions], ); const visibleOptions = matchingOptions.slice(0, MAX_VISIBLE_OPTIONS); const selectedOptions = value diff --git a/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx index 4b02dffa406..2672d32ac7a 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx @@ -1,8 +1,21 @@ -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { tagListCall } from "../networking"; import TagSelector from "./TagSelector"; +vi.mock("../networking", () => ({ + tagListCall: vi.fn(), +})); + +const mockTagListCall = vi.mocked(tagListCall); + describe("TagSelector", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTagListCall.mockResolvedValue({}); + }); + it("should render the tag selector", () => { render( {}} accessToken="test-token" />); }); @@ -18,4 +31,35 @@ describe("TagSelector", () => { expect(tagSelector).toHaveValue("new-tag"); } }); + + it("does not match shared dynamic tag descriptions", async () => { + mockTagListCall.mockResolvedValue({ + first: { + name: "dynamic-tag-one", + description: "This is just a spend tag that was passed dynamically", + models: [], + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + second: { + name: "dynamic-tag-two", + description: "This is just a spend tag that was passed dynamically", + models: [], + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + }); + const user = userEvent.setup(); + render( {}} accessToken="test-token" />); + + await waitFor(() => expect(mockTagListCall).toHaveBeenCalledWith("test-token")); + const input = screen.getByRole("combobox"); + await waitFor(() => expect(input).toBeEnabled()); + await user.click(input); + expect(await screen.findByRole("option", { name: /dynamic-tag-one/ })).toBeInTheDocument(); + await user.type(input, "spend tag"); + + expect(await screen.findByRole("option", { name: 'Create "spend tag"' })).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(1); + }); }); diff --git a/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx b/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx index 7ca809a0610..cbacbf0d761 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx @@ -39,6 +39,7 @@ const TagSelector: React.FC = ({ onChange, value, className, a loading={loading} className={className} allowCustomValues + searchDescriptions={false} options={tags.map((tag) => ({ label: tag.name, value: tag.name,