From 923a49a6f8cf85da15588475341dbba379a0b35f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 15:27:05 -0700 Subject: [PATCH 1/3] test(ui): pin playground behaviour with library-agnostic queries Rewrite the playground tests that reached for antd class names so they locate controls by role, text, placeholder or lucide icon instead. Add characterisation suites for AgentBuilderView and RealtimePlayground, which had none, including tab state that must survive a round trip through another tab. Every assertion here passes against the current antd components. --- .../chat_ui/AgentBuilderView.test.tsx | 326 ++++++++++++++++++ .../chat_ui/RealtimePlayground.test.tsx | 267 ++++++++++++++ .../components/compareUI/CompareUI.test.tsx | 7 +- .../components/ComparisonPanel.test.tsx | 139 +++++++- .../components/MessageInput.test.tsx | 101 ++++-- .../components/UnifiedSelector.test.tsx | 204 +++++------ 6 files changed, 885 insertions(+), 159 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx new file mode 100644 index 00000000000..179b7b8557a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx @@ -0,0 +1,326 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AgentBuilderView from "./AgentBuilderView"; +import type { AgentModel } from "../../llm_calls/fetch_agents"; + +const modelCreateCall = vi.fn().mockResolvedValue({ model_id: "id-new" }); +const modelPatchUpdateCall = vi.fn().mockResolvedValue({}); +const modelDeleteCall = vi.fn().mockResolvedValue({}); +const keyCreateCall = vi.fn().mockResolvedValue({ key: "sk-agent-key" }); +const fetchMCPServers = vi.fn().mockResolvedValue([]); +const fetchAvailableAgentModels = vi.fn(); +const fetchAvailableModels = vi.fn().mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]); + +vi.mock("@/components/networking", () => ({ + proxyBaseUrl: "https://proxy.example.com", + modelCreateCall: (...args: unknown[]) => modelCreateCall(...args), + modelPatchUpdateCall: (...args: unknown[]) => modelPatchUpdateCall(...args), + modelDeleteCall: (...args: unknown[]) => modelDeleteCall(...args), + keyCreateCall: (...args: unknown[]) => keyCreateCall(...args), + fetchMCPServers: (...args: unknown[]) => fetchMCPServers(...args), +})); + +vi.mock("../../llm_calls/fetch_agents", () => ({ + fetchAvailableAgentModels: (...args: unknown[]) => fetchAvailableAgentModels(...args), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: (...args: unknown[]) => fetchAvailableModels(...args), +})); + +vi.mock("@/components/CodeBlock", () => ({ + default: ({ code }: { code: string }) =>
{code}
, +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +// Both tab bodies keep their own state, which is what the tab container has to +// preserve when the user moves away and comes back. +const StatefulPanel = ({ label }: { label: string }) => { + const [draft, setDraft] = useState(""); + return setDraft(event.target.value)} />; +}; + +vi.mock("./ChatUI", () => ({ + default: () => , +})); + +vi.mock("../complianceUI/ComplianceUI", () => ({ + default: () => , +})); + +const AGENTS: AgentModel[] = [ + { + model_name: "support-agent", + litellm_params: { model: "litellm_agent/gpt-4o", litellm_system_prompt: "Be helpful.", temperature: 0.3 }, + model_info: { id: "agent-1" }, + }, + { + model_name: "research-agent", + litellm_params: { model: "litellm_agent/claude-sonnet-4" }, + model_info: { id: "agent-2" }, + }, +]; + +const props = { + accessToken: "sk-access", + token: "tok", + userID: "u1", + userRole: "Admin", +}; + +// The labels in the configure panel are not wired to their controls, so reach +// the control through the field that the label heads. +const controlUnder = (label: string): HTMLElement => + within(screen.getByText(label).parentElement!).getByRole("combobox"); + +const renderView = () => render(); + +const waitForRoster = async () => { + await screen.findByRole("button", { name: "support-agent litellm_agent" }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + fetchAvailableAgentModels.mockResolvedValue(AGENTS); + fetchAvailableModels.mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]); + fetchMCPServers.mockResolvedValue([]); + modelCreateCall.mockResolvedValue({ model_id: "id-new" }); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +}); + +describe("AgentBuilderView", () => { + it("asks the visitor to sign in when there is no session", () => { + render(); + + expect(screen.getByText("Sign in to use Agent Builder.")).toBeInTheDocument(); + expect(fetchAvailableAgentModels).not.toHaveBeenCalled(); + }); + + it("marks itself busy while the roster loads", async () => { + let release: (agents: AgentModel[]) => void = () => {}; + fetchAvailableAgentModels.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + renderView(); + + expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + + release(AGENTS); + await waitForRoster(); + expect(document.querySelector('[aria-busy="true"]')).not.toBeInTheDocument(); + }); + + it("lists every agent and opens the first one's configuration", async () => { + renderView(); + await waitForRoster(); + + expect(screen.getByRole("button", { name: "research-agent litellm_agent" })).toBeInTheDocument(); + expect(screen.getByText("Agent Builder")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByDisplayValue("support-agent")).toBeInTheDocument()); + expect(screen.getByDisplayValue("Be helpful.")).toBeInTheDocument(); + expect(screen.getByDisplayValue("0.3")).toBeInTheDocument(); + }); + + it("loads the configuration of whichever agent is picked", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: "research-agent litellm_agent" })); + + await waitFor(() => expect(screen.getByDisplayValue("research-agent")).toBeInTheDocument()); + }); + + it("offers a blank draft and a save control for a new agent", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + + expect(screen.getByRole("button", { name: /Save Agent/i })).toBeInTheDocument(); + expect(screen.getByDisplayValue("You are a helpful assistant.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Update Agent/i })).not.toBeInTheDocument(); + }); + + it("creates the agent under the litellm_agent prefix", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + await user.type(screen.getByPlaceholderText("My Agent"), "billing-agent"); + await user.click(screen.getByRole("button", { name: /Save Agent/i })); + + await waitFor(() => expect(modelCreateCall).toHaveBeenCalled()); + const payload = modelCreateCall.mock.calls[0][1]; + expect(payload.model_name).toBe("billing-agent"); + expect(payload.litellm_params.model).toBe("litellm_agent/gpt-4o"); + }); + + it("will not save a draft without a name", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + await user.click(screen.getByRole("button", { name: /Save Agent/i })); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); + + it("updates the selected agent through its model id", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getByRole("button", { name: /Update Agent/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(modelPatchUpdateCall.mock.calls[0][2]).toBe("agent-1"); + }); + + it("deletes only after the warning is confirmed", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]); + + expect(await screen.findByText(/Are you sure you want to delete "support-agent"/)).toBeInTheDocument(); + expect(modelDeleteCall).not.toHaveBeenCalled(); + + // The trigger and the confirmation share a label; the confirmation is the + // one rendered last, in the overlay. + const confirmations = screen.getAllByRole("button", { name: /Delete$/ }); + await user.click(confirmations[confirmations.length - 1]); + + await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("sk-access", "agent-1")); + }); + + it("abandons the delete when the warning is dismissed", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]); + await screen.findByText(/Are you sure you want to delete "support-agent"/); + await user.click(screen.getAllByRole("button", { name: /Cancel$/ }).at(-1)!); + + await waitFor(() => + expect(screen.queryByText(/Are you sure you want to delete "support-agent"/)).not.toBeInTheDocument(), + ); + expect(modelDeleteCall).not.toHaveBeenCalled(); + }); + + it("shows a ready-to-run curl example on the Connect tab", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + + const snippet = await screen.findByTestId("code-block"); + expect(snippet).toHaveTextContent("https://proxy.example.com/v1/chat/completions"); + expect(snippet).toHaveTextContent('"model": "support-agent"'); + }); + + it("mints a key scoped to the selected agent", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + await user.click(await screen.findByRole("button", { name: /Create key for this agent/i })); + + await waitFor(() => expect(keyCreateCall).toHaveBeenCalled()); + expect(keyCreateCall.mock.calls[0][2].models).toEqual(["support-agent"]); + expect(await screen.findByTestId("code-block")).toHaveTextContent("Bearer sk-agent-key"); + }); + + it("keeps a tab's own state alive while the user works in another tab", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Chat/i })); + const scratch = await screen.findByLabelText("chat scratch"); + await user.type(scratch, "half a thought"); + expect(scratch).toHaveValue("half a thought"); + + await user.click(screen.getByRole("tab", { name: /Configure/i })); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getByRole("tab", { name: /Chat/i })); + expect(await screen.findByLabelText("chat scratch")).toHaveValue("half a thought"); + }); + + it("keeps the batch tab's state alive across a round trip too", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Batch Test/i })); + await user.type(await screen.findByLabelText("batch scratch"), "seven cases"); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + await screen.findByTestId("code-block"); + + await user.click(screen.getByRole("tab", { name: /Batch Test/i })); + expect(await screen.findByLabelText("batch scratch")).toHaveValue("seven cases"); + }); + + it("attaches the MCP servers the agent should reach", async () => { + const user = userEvent.setup(); + fetchMCPServers.mockResolvedValue([{ server_id: "srv-1", alias: "github", server_name: "github-mcp" }]); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(controlUnder("MCP servers")); + const options = await screen.findAllByText("github"); + await user.click(options[options.length - 1]); + await user.keyboard("{Escape}"); + + await user.click(screen.getByRole("button", { name: /Update Agent/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(modelPatchUpdateCall.mock.calls[0][1].litellm_params.tools).toEqual([ + { type: "mcp", server_label: "litellm", server_url: "litellm_proxy/mcp/github", require_approval: "never" }, + ]); + }); + + it("warns that the builder is experimental", async () => { + renderView(); + await waitForRoster(); + + expect(screen.getByText(/Agent Builder is experimental/)).toBeInTheDocument(); + expect(within(screen.getByText(/Agent Builder is experimental/)).getByRole("link")).toHaveAttribute( + "href", + "mailto:product@berri.ai", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx new file mode 100644 index 00000000000..a72a02a59e9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx @@ -0,0 +1,267 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import RealtimePlayground from "./RealtimePlayground"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "https://proxy.example.com", +})); + +class FakeSocket { + static instances: FakeSocket[] = []; + static OPEN = 1; + + readyState = 0; + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + close = vi.fn(() => { + this.readyState = 3; + this.onclose?.(); + }); + + constructor( + public url: string, + public protocols?: string[], + ) { + FakeSocket.instances.push(this); + } + + send(payload: string) { + this.sent.push(payload); + } + + open() { + this.readyState = 1; + this.onopen?.(); + } + + emit(message: Record) { + this.onmessage?.({ data: JSON.stringify(message) }); + } +} + +const latestSocket = () => FakeSocket.instances[FakeSocket.instances.length - 1]; + +const connect = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: /Connect/i })); + await act(async () => { + latestSocket().open(); + }); +}; + +const props = { + accessToken: "sk-realtime", + selectedModel: "gpt-realtime", +}; + +beforeEach(() => { + FakeSocket.instances = []; + vi.stubGlobal("WebSocket", FakeSocket); + vi.stubGlobal( + "AudioContext", + class { + currentTime = 0; + destination = {}; + close = vi.fn(); + createBuffer = vi.fn(() => ({ getChannelData: () => new Float32Array(1), duration: 0 })); + createBufferSource = vi.fn(() => ({ connect: vi.fn(), start: vi.fn(), buffer: null })); + }, + ); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("RealtimePlayground", () => { + it("opens disconnected, with the invitation to connect", () => { + render(); + + expect(screen.getByText("Realtime Voice Chat")).toBeInTheDocument(); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.getByText("Realtime Voice Playground")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Connect/i })).toBeInTheDocument(); + }); + + it("hides the composer until a session exists", () => { + render(); + + expect(screen.queryByPlaceholderText("Type a message or use the mic...")).not.toBeInTheDocument(); + }); + + it("dials the realtime endpoint for the selected model, carrying the key as a protocol", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Connect/i })); + + expect(latestSocket().url).toBe("wss://proxy.example.com/v1/realtime?model=gpt-realtime"); + expect(latestSocket().protocols).toEqual(["realtime", "openai-insecure-api-key.sk-realtime"]); + }); + + it("passes a custom proxy base url through instead of the default", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Connect/i })); + + expect(latestSocket().url).toContain("wss://tenant.example.com/v1/realtime"); + }); + + it("appends the selected guardrails to the session url", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Connect/i })); + + expect(latestSocket().url).toContain("guardrails=pii%2Ctoxicity"); + }); + + it("refuses to dial without a model and says why", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Connect/i })); + + expect(FakeSocket.instances).toHaveLength(0); + expect(screen.getByText("Please select a model first")).toBeInTheDocument(); + }); + + it("reveals the composer and the disconnect control once the session opens", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + + expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(screen.getByText("Connected to realtime API")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Type a message or use the mic...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Disconnect/i })).toBeInTheDocument(); + expect(screen.getByTitle("Start recording")).toBeInTheDocument(); + }); + + it("configures the session against the chosen voice when it is created", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await act(async () => { + latestSocket().emit({ type: "session.created" }); + }); + + const update = JSON.parse(latestSocket().sent[0]); + expect(update.type).toBe("session.update"); + expect(update.session.voice).toBe("alloy"); + expect(update.session.type).toBe("realtime"); + }); + + it("sends what was typed and then asks for a response", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await user.type(screen.getByPlaceholderText("Type a message or use the mic..."), "hello there"); + await user.click(screen.getByRole("button", { name: /send/i })); + + const payloads = latestSocket().sent.map((raw) => JSON.parse(raw)); + expect(payloads[0].item.content[0].text).toBe("hello there"); + expect(payloads[1]).toEqual({ type: "response.create" }); + expect(screen.getByText("hello there")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Type a message or use the mic...")).toHaveValue(""); + }); + + it("will not send an empty message", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await user.click(screen.getByRole("button", { name: /send/i })); + + expect(latestSocket().sent).toHaveLength(0); + }); + + it("streams assistant text deltas into a single reply", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await act(async () => { + latestSocket().emit({ type: "response.output_text.delta", delta: "Hel" }); + latestSocket().emit({ type: "response.output_text.delta", delta: "lo!" }); + }); + + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + it("falls back to the completed response when no delta arrived", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await act(async () => { + latestSocket().emit({ + type: "response.done", + response: { output: [{ content: [{ type: "output_audio", transcript: "spoken reply" }] }] }, + }); + }); + + expect(screen.getByText("spoken reply")).toBeInTheDocument(); + }); + + it("shows what the microphone heard", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await act(async () => { + latestSocket().emit({ + type: "conversation.item.input_audio_transcription.completed", + transcript: "what is the weather", + }); + }); + + expect(screen.getByText("what is the weather")).toBeInTheDocument(); + }); + + it("surfaces an error frame in the transcript", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + await act(async () => { + latestSocket().emit({ type: "error", error: { message: "rate limited" } }); + }); + + expect(screen.getByText("Error: rate limited")).toBeInTheDocument(); + }); + + it("closes the socket and returns to the disconnected state", async () => { + const user = userEvent.setup(); + render(); + + await connect(user); + const socket = latestSocket(); + await user.click(screen.getByRole("button", { name: /Disconnect/i })); + + expect(socket.close).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByRole("button", { name: /Connect/i })).toBeInTheDocument()); + expect(screen.queryByPlaceholderText("Type a message or use the mic...")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.test.tsx index 4278cb6a0e4..a68513b5204 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import CompareUI from "./CompareUI"; @@ -108,10 +108,7 @@ describe("CompareUI", () => { let comparisonPanels = container.querySelectorAll('[data-testid^="comparison-panel-"]'); expect(comparisonPanels).toHaveLength(2); - const addButtons = Array.from(container.querySelectorAll('button[class*="ant-btn"]')); - const addComparisonButton = addButtons.find((btn) => btn.textContent?.includes("Add Comparison")); - expect(addComparisonButton).toBeInTheDocument(); - await user.click(addComparisonButton!); + await user.click(screen.getByRole("button", { name: /Add Comparison/i })); // Wait for the new comparison panel to be added (should have 3 total now) await waitFor(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.test.tsx index c07ad367606..f18ea86b8d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ComparisonInstance } from "../CompareUI"; @@ -76,20 +76,139 @@ const mockProps = { apiKey: "test-api-key", }; +// Both the settings and the remove control are icon-only buttons whose lucide +// icon survives the migration, so locate them by that icon. +const buttonWithIcon = (icon: string): HTMLButtonElement => { + const match = Array.from(document.querySelectorAll("button")).find((button) => + button.querySelector(`svg.lucide-${icon}`), + ); + if (!match) throw new Error(`no button carrying the ${icon} icon`); + return match; +}; + +const openSettings = async (user: ReturnType) => { + await user.click(buttonWithIcon("settings")); + await screen.findByText("General Settings"); +}; + describe("ComparisonPanel", () => { - it("should render", () => { - const { getByTestId } = render(); - expect(getByTestId("unified-selector")).toBeInTheDocument(); - expect(getByTestId("message-display")).toBeInTheDocument(); + it("renders the selector and the transcript", () => { + render(); + + expect(screen.getByTestId("unified-selector")).toBeInTheDocument(); + expect(screen.getByTestId("message-display")).toBeInTheDocument(); }); - it("should call onRemove when remove button is clicked", async () => { + it("removes the panel when the remove control is used", async () => { const user = userEvent.setup(); const onRemove = vi.fn(); - const { container } = render(); - const removeButton = container.querySelector('button[class*="text-red-600"]'); - expect(removeButton).toBeInTheDocument(); - await user.click(removeButton!); + render(); + + await user.click(buttonWithIcon("x")); + expect(onRemove).toHaveBeenCalledTimes(1); }); + + it("hides the remove control on the last remaining panel", () => { + render(); + + expect(() => buttonWithIcon("x")).toThrow(); + }); + + it("keeps the settings out of sight until the gear is used", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("General Settings")).not.toBeInTheDocument(); + + await openSettings(user); + + expect(screen.getByText("General Settings")).toBeInTheDocument(); + expect(screen.getByText("Advanced Settings")).toBeInTheDocument(); + expect(screen.getByTestId("tag-selector")).toBeInTheDocument(); + expect(screen.getByTestId("vector-store-selector")).toBeInTheDocument(); + expect(screen.getByTestId("guardrail-selector")).toBeInTheDocument(); + }); + + it("shows the current temperature and token ceiling", async () => { + const user = userEvent.setup(); + render(); + + await openSettings(user); + + expect(screen.getByText("Temperature")).toBeInTheDocument(); + expect(screen.getByText("1.00")).toBeInTheDocument(); + expect(screen.getByText("Max Tokens")).toBeInTheDocument(); + expect(screen.getByText("2048")).toBeInTheDocument(); + + const ranges = Array.from(document.querySelectorAll("[aria-valuenow]")); + expect(ranges.map((range) => range.getAttribute("aria-valuenow"))).toEqual(["1", "2048"]); + }); + + it("pushes the whole parameter set to every panel when sync is switched on", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(); + render(); + + await openSettings(user); + await user.click(screen.getByRole("checkbox", { name: /Sync Settings Across Models/i })); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + const [updates, options] = onUpdate.mock.calls[0]; + expect(updates.applyAcrossModels).toBe(true); + expect(updates.temperature).toBe(1); + expect(updates.maxTokens).toBe(2048); + expect(options.applyToAll).toBe(true); + expect(options.keysToApply).toContain("temperature"); + expect(options.keysToApply).toContain("maxTokens"); + }); + + it("turns sync off without resetting the values it was sharing", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(); + render( + , + ); + + await openSettings(user); + await user.click(screen.getByRole("checkbox", { name: /Sync Settings Across Models/i })); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + expect(onUpdate.mock.calls[0][0]).toEqual({ applyAcrossModels: false }); + }); + + it("keeps an advanced-parameter toggle local while sync is off", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(); + render(); + + await openSettings(user); + await user.click(screen.getByRole("checkbox", { name: /Use Advanced Parameters/i })); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + expect(onUpdate.mock.calls[0][0]).toEqual({ useAdvancedParams: true }); + expect(onUpdate.mock.calls[0][1]).toBeUndefined(); + }); + + it("fans an advanced-parameter toggle out to every panel while sync is on", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(); + render( + , + ); + + await openSettings(user); + await user.click(screen.getByRole("checkbox", { name: /Use Advanced Parameters/i })); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + expect(onUpdate.mock.calls[0][1]).toEqual({ applyToAll: true, keysToApply: ["useAdvancedParams"] }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageInput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageInput.test.tsx index eb157904165..049b70b982c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageInput.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageInput.test.tsx @@ -1,42 +1,95 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { MessageInput } from "./MessageInput"; +const PLACEHOLDER = "Type your message... (Shift+Enter for new line)"; + describe("MessageInput", () => { - it("should render", () => { - const onChange = vi.fn(); - const onSend = vi.fn(); - const { container } = render(); - const textarea = container.querySelector("textarea"); - const button = container.querySelector("button"); - expect(textarea).toBeInTheDocument(); - expect(button).toBeInTheDocument(); + it("renders a message box and a send control", () => { + render(); + + expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); }); - it("should disable send button initially", () => { + it("reports every keystroke through onChange", async () => { + const user = userEvent.setup(); const onChange = vi.fn(); - const onSend = vi.fn(); - const { container } = render(); - const button = container.querySelector("button") as HTMLButtonElement; + render(); - expect(button).toBeDisabled(); + await user.type(screen.getByPlaceholderText(PLACEHOLDER), "hi"); + + expect(onChange).toHaveBeenCalledWith("h"); }); - it("should enable send button when hasAttachment is true even with empty value", () => { - const onChange = vi.fn(); + it("refuses to send while the box is empty and nothing is attached", async () => { + const user = userEvent.setup(); const onSend = vi.fn(); - const uploadComponent =
Upload
; - const { container, getByTestId } = render( + render(); + + const send = screen.getByRole("button"); + expect(send).toBeDisabled(); + + await user.click(send); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("sends once the box holds text", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + const send = screen.getByRole("button"); + expect(send).not.toBeDisabled(); + + await user.click(send); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it("sends on an attachment alone and surfaces the upload control", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( Upload} />, ); - const button = container.querySelector("button") as HTMLButtonElement; - expect(getByTestId("upload-component")).toBeInTheDocument(); - expect(button).not.toBeDisabled(); + + expect(screen.getByTestId("upload-component")).toBeInTheDocument(); + + const send = screen.getByRole("button"); + expect(send).not.toBeDisabled(); + + await user.click(send); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it("stays inert while disabled even with text present", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + await user.click(screen.getByRole("button")); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("sends on Enter but adds a newline on Shift+Enter", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + const box = screen.getByPlaceholderText(PLACEHOLDER); + await user.click(box); + + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(onSend).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + expect(onSend).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.test.tsx index 1c6951fc421..4686804afec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.test.tsx @@ -4,158 +4,122 @@ import { describe, expect, it, vi } from "vitest"; import { UnifiedSelector } from "./UnifiedSelector"; import { EndpointId, ENDPOINT_CONFIGS } from "../endpoint_config"; +const CHAT = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; +const AGENTS = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS]; + +// A select's prompt text is a rendered node in one library and an input +// placeholder attribute in the other, so accept either. +const promptIsVisible = (text: string) => + screen.queryByText(text) !== null || screen.queryByPlaceholderText(text) !== null; + +const openList = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); +}; + describe("UnifiedSelector", () => { - it("should render", () => { - const onChange = vi.fn(); - const options = [ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - ]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; - - render(); - - const select = screen.getByRole("combobox"); - expect(select).toBeInTheDocument(); - }); - - it("should display placeholder when not loading", () => { - const onChange = vi.fn(); - const options = [{ value: "option1", label: "Option 1" }]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; - - const { container } = render( - , + it("renders a combobox", () => { + render( + , ); - const placeholder = container.querySelector(".ant-select-selection-placeholder"); - expect(placeholder).toHaveTextContent(config.selectorPlaceholder); + expect(screen.getByRole("combobox")).toBeInTheDocument(); }); - it("should display loading placeholder when loading", () => { - const onChange = vi.fn(); - const options = [{ value: "option1", label: "Option 1" }]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + it("prompts with the endpoint's own selector copy", () => { + render(); - const { container } = render( - , - ); - - const placeholder = container.querySelector(".ant-select-selection-placeholder"); - expect(placeholder).toHaveTextContent(`Loading ${config.selectorLabel.toLowerCase()}s...`); + expect(promptIsVisible(CHAT.selectorPlaceholder)).toBe(true); }); - it("should call onChange when option is selected", async () => { + it("prompts with the agent endpoint's copy when configured for agents", () => { + render(); + + expect(promptIsVisible(AGENTS.selectorPlaceholder)).toBe(true); + }); + + it("swaps the prompt for a loading message while options are in flight", () => { + render(); + + expect(promptIsVisible(`Loading ${CHAT.selectorLabel.toLowerCase()}s...`)).toBe(true); + }); + + it("reports the chosen option's value, not its label", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - const options = [ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - ]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; - - render(); - - const select = screen.getByRole("combobox"); - await user.click(select); - - await waitFor(() => { - const option = screen.getByText("Option 1"); - expect(option).toBeInTheDocument(); - }); - - const option = screen.getByText("Option 1"); - await user.click(option); - - await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - }); - const callArgs = onChange.mock.calls[0]; - expect(callArgs[0]).toBe("option1"); - }); - - it("should display selected value", () => { - const onChange = vi.fn(); - const options = [ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - ]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; - - const { container } = render( - , + render( + , ); - const selectedValue = container.querySelector(".ant-select-selection-item"); - expect(selectedValue).toHaveTextContent("Option 1"); + await openList(user); + + // antd renders a hidden measurement copy of each option, so take the last + // match: it is the live one, and it is the only one when there is no copy. + const matches = await screen.findAllByText("Option Two"); + await user.click(matches[matches.length - 1]); + + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(onChange.mock.calls[0][0]).toBe("option2"); }); - it("should filter options by search input", async () => { + it("narrows the list as the user searches", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); - const options = [ - { value: "option1", label: "Option One" }, - { value: "option2", label: "Option Two" }, - { value: "option3", label: "Different" }, - ]; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + render( + , + ); - render(); - - const select = screen.getByRole("combobox"); - await user.click(select); - await user.type(select, "One"); + const combobox = screen.getByRole("combobox"); + await user.click(combobox); + await user.type(combobox, "One"); await waitFor(() => { - expect(screen.getByText("Option One")).toBeInTheDocument(); + expect(screen.getAllByText("Option One").length).toBeGreaterThan(0); expect(screen.queryByText("Option Two")).not.toBeInTheDocument(); expect(screen.queryByText("Different")).not.toBeInTheDocument(); }); }); - it("should show loading spinner in notFoundContent when loading", async () => { + it("shows a busy indicator in the empty list while loading", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); - const options: { value: string; label: string }[] = []; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + render(); - render(); - - const select = screen.getByRole("combobox"); - await user.click(select); + await openList(user); await waitFor(() => { - const spin = document.querySelector(".ant-spin"); - expect(spin).toBeInTheDocument(); + expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); }); }); - it("should show no options message when not loading and no options", async () => { + it("says so when there is nothing to pick and nothing is loading", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); - const options: { value: string; label: string }[] = []; - const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]; + render(); - render(); + await openList(user); - const select = screen.getByRole("combobox"); - await user.click(select); - - await waitFor(() => { - expect(screen.getByText(`No ${config.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument(); - }); - }); - - it("should work with agent endpoint config", () => { - const onChange = vi.fn(); - const options = [{ value: "agent1", label: "Agent One" }]; - const config = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS]; - - const { container } = render( - , - ); - - const placeholder = container.querySelector(".ant-select-selection-placeholder"); - expect(placeholder).toHaveTextContent(config.selectorPlaceholder); + expect(await screen.findByText(`No ${CHAT.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument(); }); }); From 9a26da90a47459d249030b6873ec99619161fdc9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 15:59:53 -0700 Subject: [PATCH 2/3] refactor(ui): migrate playground to shadcn Replaces antd and Tremor with shadcn primitives across the six route-owned, form-free playground components: the compare view and its panel, message input and unified selector, plus the realtime playground and the agent builder. Markup only, no behaviour change. The characterisation tests added in the previous commit are untouched here and stay green through the swap. Adds ui/slider.tsx via the shadcn CLI and retires the six antd no-restricted-imports suppressions the migration made obsolete. --- ui/litellm-dashboard/eslint-suppressions.json | 30 +- .../components/chat_ui/AgentBuilderView.tsx | 530 +++++++++--------- .../components/chat_ui/RealtimePlayground.tsx | 77 +-- .../components/compareUI/CompareUI.tsx | 76 ++- .../compareUI/components/ComparisonPanel.tsx | 75 ++- .../compareUI/components/MessageInput.tsx | 33 +- .../compareUI/components/UnifiedSelector.tsx | 66 ++- .../src/components/ui/slider.tsx | 41 ++ 8 files changed, 514 insertions(+), 414 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ui/slider.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index d7f71a5840d..0c5bcc65702 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1055,9 +1055,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 5 } @@ -1098,9 +1095,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 }, @@ -1115,9 +1109,6 @@ "no-nested-ternary": { "count": 4 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1125,9 +1116,6 @@ "src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx": { "local/no-complex-jsx-arrow": { "count": 2 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx": { @@ -1135,21 +1123,11 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/compareUI/components/MessageInput.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": { "no-restricted-imports": { "count": 2 } }, - "src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": { "local/no-complex-jsx-arrow": { "count": 2 @@ -3582,6 +3560,14 @@ "count": 1 } }, + "src/components/ui/slider.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-nested-ternary": { + "count": 1 + } + }, "src/components/ui/switch.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx index d4333b95c62..44995707b10 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx @@ -1,16 +1,24 @@ "use client"; -import { - CommentOutlined, - DeleteOutlined, - ExperimentOutlined, - LinkOutlined, - PlusOutlined, - RobotOutlined, - SaveOutlined, -} from "@ant-design/icons"; -import { Button, Input, Modal, Select, Spin, Tabs } from "antd"; +import { Bot, FlaskConical, Link as LinkIcon, MessageSquare, Plus, Save, Trash2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import CodeBlock from "@/components/CodeBlock"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { @@ -27,8 +35,6 @@ import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_m import ComplianceUI from "../complianceUI/ComplianceUI"; import ChatUI from "./ChatUI"; -const { TextArea } = Input; - export interface AgentBuilderViewProps { accessToken: string | null; token: string | null; @@ -45,6 +51,8 @@ export interface AgentBuilderViewProps { const NEW_AGENT_ID = "__new__"; +type AgentTab = "configure" | "chat" | "test" | "connect"; + function getConnectTabBaseUrl( proxySettings: AgentBuilderViewProps["proxySettings"], customProxyBaseUrl?: string, @@ -116,7 +124,7 @@ function ConnectTabContent({ Create a virtual key that can only call this agent. The key will be scoped to you (user_id) and restricted to the model {agentName}.

- {disabledPersonalKeyCreation && ( @@ -190,7 +198,12 @@ export default function AgentBuilderView({ const [modelGroups, setModelGroups] = useState([]); const [loadingAgents, setLoadingAgents] = useState(true); const [selectedId, setSelectedId] = useState(null); - const [activeTab, setActiveTab] = useState<"configure" | "chat" | "test" | "connect">("configure"); + const [activeTab, setActiveTab] = useState("configure"); + const { onTabChange, hasVisited } = useVisitedTabs("configure"); + const goToTab = (tab: AgentTab) => { + setActiveTab(tab); + onTabChange(tab); + }; const [creatingKey, setCreatingKey] = useState(false); const [createdKeyValue, setCreatedKeyValue] = useState(null); @@ -207,6 +220,7 @@ export default function AgentBuilderView({ const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); const effectiveApiKey = apiKey || accessToken || ""; const selectedAgent = @@ -314,7 +328,7 @@ export default function AgentBuilderView({ setDraftTemperature(0.7); setDraftMaxTokens(4096); setDraftTools([]); - setActiveTab("configure"); + goToTab("configure"); }; const handleSaveAgent = async () => { @@ -344,7 +358,7 @@ export default function AgentBuilderView({ ? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim()) : list.find((a) => a.model_name === draftName.trim()); setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null); - setActiveTab("chat"); + goToTab("chat"); } catch (e) { NotificationsManager.fromBackend("Failed to save agent"); } finally { @@ -411,27 +425,24 @@ export default function AgentBuilderView({ const handleDeleteAgent = () => { if (!selectedAgent || !selectedAgentModelId || !accessToken) return; - Modal.confirm({ - title: "Delete agent", - content: `Are you sure you want to delete "${selectedAgent.model_name}"? This cannot be undone.`, - okText: "Delete", - okType: "danger", - cancelText: "Cancel", - onOk: async () => { - setDeleting(true); - try { - await modelDeleteCall(accessToken, selectedAgentModelId); - NotificationsManager.success("Agent deleted"); - const list = await loadAgents(); - const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId); - setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null); - } catch (e) { - NotificationsManager.fromBackend("Failed to delete agent"); - } finally { - setDeleting(false); - } - }, - }); + setConfirmingDelete(true); + }; + + const handleConfirmDelete = async () => { + if (!selectedAgent || !selectedAgentModelId || !accessToken) return; + setDeleting(true); + try { + await modelDeleteCall(accessToken, selectedAgentModelId); + NotificationsManager.success("Agent deleted"); + const list = await loadAgents(); + const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId); + setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null); + } catch (e) { + NotificationsManager.fromBackend("Failed to delete agent"); + } finally { + setDeleting(false); + setConfirmingDelete(false); + } }; if (!accessToken || !userID || !userRole) { @@ -446,13 +457,8 @@ export default function AgentBuilderView({
Agent Builder {isNewAgent ? ( - ) : ( @@ -460,7 +466,7 @@ export default function AgentBuilderView({ )}
- + Agent Builder is experimental and may change or be removed without notice. We’d love your feedback—email us at{" "} @@ -477,12 +483,14 @@ export default function AgentBuilderView({
Agents -
{loadingAgents ? ( -
- +
+
) : ( <> @@ -509,7 +517,7 @@ export default function AgentBuilderView({ onClick={handleAddAgent} className="mb-1 w-full rounded-md border border-dashed border-gray-300 px-3 py-2 text-left text-sm text-gray-500 hover:border-blue-400 hover:bg-blue-50/50 hover:text-gray-700" > - New agent + New agent )} @@ -526,227 +534,231 @@ export default function AgentBuilderView({ {(selectedId !== null || isNewAgent) && ( <> setActiveTab(k as "configure" | "chat" | "test" | "connect")} - className="flex-1 overflow-hidden [&_.ant-tabs-content]:h-full [&_.ant-tabs-tabpane]:h-full [&_.ant-tabs-nav]:pl-4" - items={[ - { - key: "configure", - label: ( - - Configure - - ), - children: ( -
- {isNewAgent || selectedAgent ? ( -
- {!selectedAgentModelId && selectedAgent && ( -
- This agent cannot be updated or deleted here (missing model id). Manage it from Models - & Endpoints. -
- )} -
- - setDraftName(e.target.value)} - placeholder="My Agent" - /> -
-
- -