feat(ui): migrate ChatUI off Ant Design and Tremor

Replace ChatUI cards, inputs, dialogs, popovers, MCP selects, uploads,
tooltips, and icons with shadcn/Base UI and Lucide. Update ChatUI tests
to drive searchable combobox controls instead of Ant Design selectors
This commit is contained in:
mubashir1osmani 2026-08-06 14:33:39 -07:00
parent ea654d11a5
commit bb5d9a199a
3 changed files with 638 additions and 824 deletions

View file

@ -1,10 +1,10 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChatUI from "./ChatUI";
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
// Mock the fetchAvailableModels function
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
@ -13,15 +13,18 @@ vi.mock("@/components/llm_calls/chat_completion", () => ({
makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined),
}));
// Mock other networking functions that cause errors
vi.mock("@/components/networking", () => ({
tagListCall: vi.fn().mockResolvedValue({ data: [] }),
tagListCall: vi.fn().mockResolvedValue({}),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
getGuardrailsList: vi.fn().mockResolvedValue({ data: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ data: [] }),
modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
fetchMCPServers: vi.fn().mockResolvedValue([]),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
callMCPTool: vi.fn(),
}));
// Mock scrollIntoView which is not available in jsdom
beforeEach(() => {
Element.prototype.scrollIntoView = () => {};
});
@ -29,17 +32,27 @@ beforeEach(() => {
const CHAT_REQUEST_ARG_COUNT = 26;
const STREAMING_ENABLED_ARG_INDEX = 25;
async function openComboboxByPlaceholder(placeholder: string) {
const user = userEvent.setup();
const combobox = screen.getByPlaceholderText(placeholder);
await user.click(combobox);
return combobox;
}
async function selectComboboxOption(placeholder: string, optionLabel: string) {
const user = userEvent.setup();
await openComboboxByPlaceholder(placeholder);
const option = await screen.findByText(optionLabel);
await user.click(option);
}
describe("ChatUI", () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
sessionStorage.clear();
// Mock scrollIntoView which is not available in JSDOM
Element.prototype.scrollIntoView = vi.fn();
// Mock the fetchAvailableModels to return test models
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
{ model_group: "Model 1", mode: "chat" },
{ model_group: "Model 2", mode: "chat" },
{ model_group: "Model 3", mode: "chat" },
@ -47,7 +60,7 @@ describe("ChatUI", () => {
});
it("should render the chat UI", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -56,11 +69,11 @@ describe("ChatUI", () => {
disabledPersonalKeyCreation={false}
/>,
);
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
it("should show the voice selector when the endpoint type is audio_speech", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -70,47 +83,20 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Find the endpoint selector by looking for the "Endpoint Type:" text and its associated Select
const endpointTypeText = getByText("Endpoint Type");
const selectContainer = endpointTypeText.parentElement;
const selectElement = selectContainer?.querySelector(".ant-select-selector");
await selectComboboxOption("Select an endpoint", "/v1/audio/speech");
expect(selectElement).toBeInTheDocument();
// Click on the select to open the dropdown
if (selectElement) {
fireEvent.mouseDown(selectElement);
}
// Wait for the dropdown to appear and find the audio_speech option
await waitFor(() => {
const audioSpeechOption = screen.getByText("/v1/audio/speech");
expect(audioSpeechOption).toBeInTheDocument();
expect(screen.getByText("Voice")).toBeInTheDocument();
expect(screen.getByLabelText("Voice")).toBeInTheDocument();
});
// Click on the audio_speech option
const audioSpeechOption = screen.getByText("/v1/audio/speech");
fireEvent.click(audioSpeechOption);
// Verify the voice selector appears
await waitFor(() => {
expect(getByText("Voice")).toBeInTheDocument();
});
// Verify the voice select component is present
const voiceText = getByText("Voice");
const voiceSelectContainer = voiceText.parentElement;
const voiceSelectElement = voiceSelectContainer?.querySelector(".ant-select");
expect(voiceSelectElement).toBeInTheDocument();
});
it("should allow the user to select a model", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -120,35 +106,26 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open the "Select Model" dropdown (AntD renders options in a portal)
const selectModelLabel = getByText("Select Model");
// The Select component is a sibling of the Text component, so we need to find it in the parent container
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
fireEvent.mouseDown(modelSelect!);
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
const model1Label = screen.getAllByText("Model 1");
expect(model1Label.length).toBeGreaterThan(0);
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
});
it("shows only chat-compatible models when chat endpoint is selected", async () => {
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValueOnce([
it("shows all models returned for the active key regardless of endpoint", async () => {
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
{ model_group: "SpeechModel", mode: "audio_speech" },
{ model_group: "ImageModel", mode: "image_generation" },
{ model_group: "ResponsesModel", mode: "responses" },
]);
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -159,43 +136,22 @@ describe("ChatUI", () => {
);
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open endpoint selector and explicitly select /v1/chat/completions
const endpointTypeText = getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
expect(endpointSelect).toBeTruthy();
act(() => {
fireEvent.mouseDown(endpointSelect!);
fireEvent.click(screen.getByText("/v1/chat/completions"));
});
// Open model selector
const selectModelLabel = getByText("Select Model");
// The Select component is a sibling of the Text component, so we need to find it in the parent container
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
act(() => {
fireEvent.mouseDown(modelSelect!);
});
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
// Chat-compatible: ChatModel should be visible
expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0);
expect(screen.queryByText("SpeechModel")).toBeNull();
expect(screen.queryByText("ImageModel")).toBeNull();
expect(screen.queryByText("ResponsesModel")).toBeNull();
expect(screen.getAllByText("SpeechModel").length).toBeGreaterThan(0);
expect(screen.getAllByText("ImageModel").length).toBeGreaterThan(0);
expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0);
});
});
/**
* Tests that the 'Enter custom model' option is available in the model selector dropdown.
* This ensures users can manually enter a model name if it's not in the list.
*/
it("should show 'Enter custom model' option in model selector", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -205,24 +161,14 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open the "Select Model" dropdown
const selectModelLabel = getByText("Select Model");
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
fireEvent.mouseDown(modelSelect!);
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
// Get all options in the dropdown (Ant Design renders these in a portal)
const options = document.querySelectorAll(".ant-select-item-option-content");
expect(options.length).toBeGreaterThan(0);
// Check if the first option is 'Enter custom model'
expect(options[0]).toHaveTextContent("Enter custom model");
expect(screen.getByText("Enter custom model")).toBeInTheDocument();
});
});
@ -241,44 +187,23 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const endpointTypeText = screen.getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector") as HTMLElement | null;
expect(endpointSelect).not.toBeNull();
const mcpInput = () => screen.getByLabelText("Select MCP servers");
const selectEndpointOption = async (label: string) => {
act(() => {
fireEvent.mouseDown(endpointSelect!);
});
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
act(() => {
fireEvent.click(screen.getByText(label));
});
};
const getMcpSelect = () =>
screen.getByText("MCP Servers").closest("div")?.querySelector(".ant-select") as HTMLElement | null;
await selectEndpointOption("/v1/embeddings");
const mcpSelect = getMcpSelect();
expect(mcpSelect).not.toBeNull();
await selectComboboxOption("Select an endpoint", "/v1/embeddings");
await waitFor(() => {
expect(mcpSelect).toHaveClass("ant-select-disabled");
expect(mcpInput()).toBeDisabled();
});
await selectEndpointOption("/v1/chat/completions");
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
await waitFor(() => {
expect(mcpSelect).not.toHaveClass("ant-select-disabled");
expect(mcpInput()).not.toBeDisabled();
});
});
it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -293,35 +218,13 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Model Settings button only appears when a chat model is selected; select "Model 1" first
const selectModelLabel = screen.getByText("Select Model");
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await selectComboboxOption("Select a Model", "Model 1");
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
// Ant Design Select options may not have role="option"; click the dropdown option by text
const model1Options = screen.getAllByText("Model 1");
await act(async () => {
fireEvent.click(model1Options[model1Options.length - 1]);
});
await waitFor(() => {
const modelSettingsButton = screen.getByTestId("model-settings-button");
expect(modelSettingsButton).toBeInTheDocument();
});
const modelSettingsButton = screen.getByTestId("model-settings-button");
await act(async () => {
fireEvent.click(modelSettingsButton);
});
await user.click(screen.getByTestId("model-settings-button"));
await waitFor(() => {
expect(screen.getByText("Model Settings")).toBeInTheDocument();
@ -333,9 +236,7 @@ describe("ChatUI", () => {
});
expect(fallbacksCheckbox).not.toBeChecked();
await act(async () => {
fireEvent.click(fallbacksCheckbox);
});
await user.click(fallbacksCheckbox);
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i })).toBeChecked();
@ -343,6 +244,7 @@ describe("ChatUI", () => {
});
it("should send the chat request non-streaming after Stream responses is unchecked", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -357,35 +259,18 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const selectModelLabel = screen.getByText("Select Model");
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
const model1Options = screen.getAllByText("Model 1");
await act(async () => {
fireEvent.click(model1Options[model1Options.length - 1]);
});
await selectComboboxOption("Select a Model", "Model 1");
await waitFor(() => {
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("model-settings-button"));
});
await user.click(screen.getByTestId("model-settings-button"));
const streamingCheckbox = await screen.findByRole("checkbox", { name: /Stream responses/i });
expect(streamingCheckbox).toBeChecked();
await act(async () => {
fireEvent.click(streamingCheckbox);
});
await user.click(streamingCheckbox);
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: /Stream responses/i })).not.toBeChecked();
@ -446,7 +331,8 @@ describe("ChatUI", () => {
});
it("should offer the streaming toggle for a responses-only model without advanced params", async () => {
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
const user = userEvent.setup();
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
{ model_group: "ResponsesModel", mode: "responses" },
]);
@ -464,37 +350,14 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const endpointTypeText = screen.getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(endpointSelect!);
});
await act(async () => {
fireEvent.click(screen.getByText("/v1/responses"));
});
const selectModelLabel = screen.getByText("Select Model");
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await waitFor(() => {
expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0);
});
const modelOptions = screen.getAllByText("ResponsesModel");
await act(async () => {
fireEvent.click(modelOptions[modelOptions.length - 1]);
});
await selectComboboxOption("Select an endpoint", "/v1/responses");
await selectComboboxOption("Select a Model", "ResponsesModel");
await waitFor(() => {
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("model-settings-button"));
});
await user.click(screen.getByTestId("model-settings-button"));
expect(await screen.findByRole("checkbox", { name: /Stream responses/i })).toBeChecked();
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
@ -543,6 +406,7 @@ describe("ChatUI", () => {
});
it("should enable search functionality for MCP server selector", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -557,27 +421,16 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const mcpServersText = screen.queryByText("MCP Servers");
expect(mcpServersText).toBeInTheDocument();
expect(screen.getByText("MCP Servers")).toBeInTheDocument();
if (mcpServersText) {
const selectContainer = mcpServersText.parentElement?.nextElementSibling;
const selectElement = selectContainer?.querySelector(".ant-select-selector");
expect(selectElement).toBeInTheDocument();
const mcpInput = screen.getByLabelText("Select MCP servers");
expect(mcpInput).toBeInTheDocument();
expect(mcpInput).not.toBeDisabled();
if (selectElement) {
fireEvent.mouseDown(selectElement);
await user.click(mcpInput);
await waitFor(() => {
const allServersOption = screen.queryByText("All MCP Servers");
if (allServersOption) {
expect(allServersOption).toBeInTheDocument();
}
});
const searchInput = document.querySelector(".ant-select-selection-search-input");
expect(searchInput).toBeInTheDocument();
}
}
await waitFor(() => {
expect(screen.getByText("All MCP Servers")).toBeInTheDocument();
});
});
});

View file

@ -53,19 +53,25 @@ export function MultiSelect({
className,
}: MultiSelectProps) {
const [query, setQuery] = useState("");
const selectedOptions = value.map(
(selectedValue) =>
options.find((option) => option.value === selectedValue) ?? {
label: selectedValue,
value: selectedValue,
},
const safeOptions = options.filter(
(option): option is MultiSelectOption =>
option != null && typeof option.value === "string" && option.value.length > 0,
);
const selectedOptions = value
.filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0)
.map(
(selectedValue) =>
safeOptions.find((option) => option.value === selectedValue) ?? {
label: selectedValue,
value: selectedValue,
},
);
const customOption = query.trim();
const customOptionExists = options.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
const items =
allowCustomValues && customOption && !customOptionExists
? [...options, { label: `Create "${customOption}"`, value: customOption }]
: options;
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
: safeOptions;
return (
<Combobox