Merge pull request #36847 from BerriAI/litellm_/awesome-shirley-6a1a86

refactor(ui): migrate playground to shadcn
This commit is contained in:
yuneng-jiang 2026-08-13 18:30:48 -07:00 committed by GitHub
commit 2913ee811c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1385 additions and 573 deletions

View file

@ -1052,9 +1052,6 @@
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
@ -1095,9 +1092,6 @@
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 2
},
@ -1112,9 +1106,6 @@
"no-nested-ternary": {
"count": 4
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1122,9 +1113,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": {
@ -1132,21 +1120,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
@ -3531,6 +3509,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

View file

@ -0,0 +1,320 @@
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 }) => <pre data-testid="code-block">{code}</pre>,
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
const StatefulPanel = ({ label }: { label: string }) => {
const [draft, setDraft] = useState("");
return <input aria-label={label} value={draft} onChange={(event) => setDraft(event.target.value)} />;
};
vi.mock("./ChatUI", () => ({
default: () => <StatefulPanel label="chat scratch" />,
}));
vi.mock("../complianceUI/ComplianceUI", () => ({
default: () => <StatefulPanel label="batch scratch" />,
}));
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",
};
const controlUnder = (label: string): HTMLElement =>
within(screen.getByText(label).parentElement!).getByRole("combobox");
const renderView = () => render(<AgentBuilderView {...props} />);
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(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
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<AgentModel[]>((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();
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",
);
});
});

View file

@ -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 <span className="font-mono text-gray-800">{agentName}</span>.
</p>
<Button type="primary" onClick={onCreateKey} loading={creatingKey} disabled={disabledPersonalKeyCreation}>
<Button onClick={onCreateKey} disabled={creatingKey || disabledPersonalKeyCreation}>
Create key for this agent
</Button>
{disabledPersonalKeyCreation && (
@ -190,7 +198,12 @@ export default function AgentBuilderView({
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
const [loadingAgents, setLoadingAgents] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"configure" | "chat" | "test" | "connect">("configure");
const [activeTab, setActiveTab] = useState<AgentTab>("configure");
const { onTabChange, hasVisited } = useVisitedTabs("configure");
const goToTab = (tab: AgentTab) => {
setActiveTab(tab);
onTabChange(tab);
};
const [creatingKey, setCreatingKey] = useState(false);
const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(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({
<div className="flex h-12 items-center justify-between px-4">
<span className="text-sm font-medium text-gray-900">Agent Builder</span>
{isNewAgent ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAgent}
loading={saving}
disabled={!draftName?.trim() || !draftUnderlyingModel}
>
<Button onClick={handleSaveAgent} disabled={saving || !draftName?.trim() || !draftUnderlyingModel}>
<Save />
Save Agent
</Button>
) : (
@ -460,7 +466,7 @@ export default function AgentBuilderView({
)}
</div>
<div className="flex items-center gap-2 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
<ExperimentOutlined className="shrink-0 text-amber-600" />
<FlaskConical className="size-4 shrink-0 text-amber-600" />
<span>
Agent Builder is experimental and may change or be removed without notice. Wed love your feedbackemail us
at{" "}
@ -477,12 +483,14 @@ export default function AgentBuilderView({
<div className="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="flex items-center justify-between border-b border-gray-200 p-3">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Agents</span>
<Button type="text" size="small" icon={<PlusOutlined />} onClick={handleAddAgent} aria-label="Add agent" />
<Button variant="ghost" size="icon-sm" onClick={handleAddAgent} aria-label="Add agent">
<Plus />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loadingAgents ? (
<div className="flex justify-center py-4">
<Spin size="small" />
<div className="flex justify-center py-4" aria-busy="true">
<UiLoadingSpinner className="size-4 text-gray-400" />
</div>
) : (
<>
@ -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"
>
<PlusOutlined className="mr-1" /> New agent
<Plus className="mr-1 inline size-4" /> New agent
</button>
</>
)}
@ -526,227 +534,230 @@ export default function AgentBuilderView({
{(selectedId !== null || isNewAgent) && (
<>
<Tabs
activeKey={activeTab}
onChange={(k) => 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: (
<span>
<RobotOutlined className="mr-1" /> Configure
</span>
),
children: (
<div className="h-full overflow-y-auto p-6">
{isNewAgent || selectedAgent ? (
<div className="mx-auto max-w-xl space-y-4">
{!selectedAgentModelId && selectedAgent && (
<div className="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
This agent cannot be updated or deleted here (missing model id). Manage it from Models
&amp; Endpoints.
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Agent name</label>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="My Agent"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">System prompt</label>
<TextArea
value={draftSystemPrompt}
onChange={(e) => setDraftSystemPrompt(e.target.value)}
placeholder="You are a helpful assistant..."
rows={6}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Underlying LLM</label>
<Select
value={draftUnderlyingModel}
onChange={setDraftUnderlyingModel}
className="w-full"
options={modelGroups.map((m) => ({ value: m.model_group, label: m.model_group }))}
placeholder="Select model"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Temperature</label>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={draftTemperature}
onChange={(e) => setDraftTemperature(Number(e.target.value))}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Max tokens</label>
<Input
type="number"
min={1}
value={draftMaxTokens}
onChange={(e) => setDraftMaxTokens(Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">MCP servers</label>
<Select
mode="multiple"
placeholder="Select MCP servers to attach (same format as chat completions API)"
value={selectedMCPServerIds}
onChange={handleMCPServerChange}
loading={loadingMCPServers}
className="w-full"
allowClear
showSearch
optionFilterProp="label"
options={mcpServers.map((s) => ({
value: s.server_id,
label: s.alias || s.server_name || s.server_id,
}))}
/>
{selectedAgent && draftTools.length > 0 && (
<p className="mt-1 text-xs text-gray-500">
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
<code className="rounded-sm bg-gray-100 px-1">tools</code> array in chat completions
when calling this agent.
</p>
)}
</div>
{selectedAgent && (
<div className="flex flex-wrap items-center gap-2 pt-2">
{selectedAgentModelId && (
<>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleUpdateAgent}
loading={saving}
disabled={!draftName?.trim() || !draftUnderlyingModel}
>
Update Agent
</Button>
<Button
type="default"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteAgent}
loading={deleting}
>
Delete
</Button>
</>
)}
<Button type="primary" icon={<CommentOutlined />} onClick={() => setActiveTab("chat")}>
Test in Chat
value={activeTab}
onValueChange={(value) => goToTab(value as AgentTab)}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0 pl-4">
<TabsTrigger value="configure" className="flex-none rounded-none px-4 py-2">
<Bot />
Configure
</TabsTrigger>
<TabsTrigger value="chat" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<MessageSquare />
Chat
</TabsTrigger>
<TabsTrigger value="test" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<FlaskConical />
Batch Test
</TabsTrigger>
<TabsTrigger value="connect" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<LinkIcon />
Connect
</TabsTrigger>
</TabsList>
<TabsContent
value="configure"
keepMounted={hasVisited("configure")}
className="min-h-0 overflow-hidden"
>
<div className="h-full overflow-y-auto p-6">
{isNewAgent || selectedAgent ? (
<div className="mx-auto max-w-xl space-y-4">
{!selectedAgentModelId && selectedAgent && (
<div className="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
This agent cannot be updated or deleted here (missing model id). Manage it from Models &amp;
Endpoints.
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Agent name</label>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="My Agent"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">System prompt</label>
<Textarea
value={draftSystemPrompt}
onChange={(e) => setDraftSystemPrompt(e.target.value)}
placeholder="You are a helpful assistant..."
rows={6}
className="field-sizing-fixed"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Underlying LLM</label>
<Select
value={draftUnderlyingModel ?? null}
onValueChange={(model: string | null) => setDraftUnderlyingModel(model ?? undefined)}
>
<SelectTrigger className="w-full" aria-label="Underlying LLM">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{modelGroups.map((m) => (
<SelectItem key={m.model_group} value={m.model_group}>
{m.model_group}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Temperature</label>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={draftTemperature}
onChange={(e) => setDraftTemperature(Number(e.target.value))}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Max tokens</label>
<Input
type="number"
min={1}
value={draftMaxTokens}
onChange={(e) => setDraftMaxTokens(Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">MCP servers</label>
<MultiSelect
placeholder="Select MCP servers to attach (same format as chat completions API)"
value={selectedMCPServerIds}
onValueChange={handleMCPServerChange}
loading={loadingMCPServers}
className="w-full"
options={mcpServers.map((s) => ({
value: s.server_id,
label: s.alias || s.server_name || s.server_id,
}))}
/>
{selectedAgent && draftTools.length > 0 && (
<p className="mt-1 text-xs text-gray-500">
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
<code className="rounded-sm bg-gray-100 px-1">tools</code> array in chat completions when
calling this agent.
</p>
)}
</div>
{selectedAgent && (
<div className="flex flex-wrap items-center gap-2 pt-2">
{selectedAgentModelId && (
<>
<Button
onClick={handleUpdateAgent}
disabled={saving || !draftName?.trim() || !draftUnderlyingModel}
>
<Save />
Update Agent
</Button>
</div>
<Button variant="destructive" onClick={handleDeleteAgent} disabled={deleting}>
<Trash2 />
Delete
</Button>
</>
)}
</div>
) : null}
</div>
),
},
{
key: "chat",
label: (
<span>
<CommentOutlined className="mr-1" /> Chat
</span>
),
disabled: isNewAgent,
children: (
<div className="flex h-full flex-col min-h-0">
{selectedAgent ? (
<ChatUI
key={selectedAgent.model_name}
simplified
fixedModel={selectedAgent.model_name}
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Save an agent first to test in Chat.
<Button onClick={() => goToTab("chat")}>
<MessageSquare />
Test in Chat
</Button>
</div>
)}
</div>
),
},
{
key: "test",
label: (
<span>
<ExperimentOutlined className="mr-1" /> Batch Test
</span>
),
disabled: isNewAgent,
children: (
<div className="flex h-full flex-col min-h-0">
{selectedAgent ? (
<ComplianceUI
accessToken={accessToken}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
backendMode="chat_completions"
fixedModel={selectedAgent.model_name}
proxySettings={proxySettings}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Select an agent to run batch tests.
</div>
)}
) : null}
</div>
</TabsContent>
<TabsContent value="chat" keepMounted={hasVisited("chat")} className="min-h-0 overflow-hidden">
<div className="flex h-full flex-col min-h-0">
{selectedAgent ? (
<ChatUI
key={selectedAgent.model_name}
simplified
fixedModel={selectedAgent.model_name}
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Save an agent first to test in Chat.
</div>
),
},
{
key: "connect",
label: (
<span>
<LinkOutlined className="mr-1" /> Connect
</span>
),
disabled: isNewAgent,
children: (
<div className="h-full overflow-y-auto p-6">
{selectedAgent ? (
<ConnectTabContent
agentName={selectedAgent.model_name}
proxySettings={proxySettings}
customProxyBaseUrl={customProxyBaseUrl}
accessToken={accessToken}
userID={userID}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
creatingKey={creatingKey}
createdKeyValue={createdKeyValue}
onCreateKey={handleCreateKeyForAgent}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Select an agent to see how to connect.
</div>
)}
)}
</div>
</TabsContent>
<TabsContent value="test" keepMounted={hasVisited("test")} className="min-h-0 overflow-hidden">
<div className="flex h-full flex-col min-h-0">
{selectedAgent ? (
<ComplianceUI
accessToken={accessToken}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
backendMode="chat_completions"
fixedModel={selectedAgent.model_name}
proxySettings={proxySettings}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Select an agent to run batch tests.
</div>
),
},
]}
/>
)}
</div>
</TabsContent>
<TabsContent value="connect" keepMounted={hasVisited("connect")} className="min-h-0 overflow-hidden">
<div className="h-full overflow-y-auto p-6">
{selectedAgent ? (
<ConnectTabContent
agentName={selectedAgent.model_name}
proxySettings={proxySettings}
customProxyBaseUrl={customProxyBaseUrl}
accessToken={accessToken}
userID={userID}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
creatingKey={creatingKey}
createdKeyValue={createdKeyValue}
onCreateKey={handleCreateKeyForAgent}
/>
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
Select an agent to see how to connect.
</div>
)}
</div>
</TabsContent>
</Tabs>
</>
)}
</div>
</div>
<AlertDialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete agent</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{selectedAgent?.model_name}&quot;? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction variant="outline">Cancel</AlertDialogAction>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -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<string, unknown>) {
this.onmessage?.({ data: JSON.stringify(message) });
}
}
const latestSocket = () => FakeSocket.instances[FakeSocket.instances.length - 1];
const connect = async (user: ReturnType<typeof userEvent.setup>) => {
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} customProxyBaseUrl="https://tenant.example.com" />);
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(<RealtimePlayground {...props} selectedGuardrails={["pii", "toxicity"]} />);
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(<RealtimePlayground {...props} selectedModel="" />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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(<RealtimePlayground {...props} />);
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();
});
});

View file

@ -1,13 +1,13 @@
"use client";
import { AudioMutedOutlined, AudioOutlined, CloseCircleOutlined, SendOutlined, SoundOutlined } from "@ant-design/icons";
import { Button, Input, Select, Typography } from "antd";
import { CircleX, Mic, MicOff, Send, Volume2 } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getProxyBaseUrl } from "@/components/networking";
import { OPEN_AI_VOICE_SELECT_OPTIONS } from "./chatConstants";
const { Text } = Typography;
interface RealtimeMessage {
role: "user" | "assistant" | "system" | "status";
content: string;
@ -364,28 +364,37 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-3">
<SoundOutlined className="text-lg text-blue-500" />
<Text className="font-semibold text-gray-800">Realtime Voice Chat</Text>
<Volume2 className="size-5 text-blue-500" />
<span className="font-semibold text-gray-800">Realtime Voice Chat</span>
<span className={`inline-block w-2 h-2 rounded-full ${isConnected ? "bg-green-500" : "bg-gray-300"}`} />
<Text className="text-xs text-gray-500">
<span className="text-xs text-gray-500">
{isConnected ? "Connected" : isConnecting ? "Connecting..." : "Disconnected"}
</Text>
</span>
</div>
<div className="flex items-center gap-2">
<Select
size="small"
value={selectedVoice}
onChange={setSelectedVoice}
options={OPEN_AI_VOICE_SELECT_OPTIONS}
style={{ width: 220 }}
onValueChange={(voice) => setSelectedVoice(voice ?? selectedVoice)}
disabled={isConnected}
/>
>
<SelectTrigger size="sm" className="w-[220px]" aria-label="Voice">
<SelectValue>{OPEN_AI_VOICE_SELECT_OPTIONS.find((v) => v.value === selectedVoice)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{OPEN_AI_VOICE_SELECT_OPTIONS.map((voice) => (
<SelectItem key={voice.value} value={voice.value}>
{voice.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isConnected ? (
<Button type="primary" onClick={connect} loading={isConnecting} size="small">
<Button onClick={connect} disabled={isConnecting} size="sm">
Connect
</Button>
) : (
<Button danger onClick={disconnect} size="small" icon={<CloseCircleOutlined />}>
<Button variant="destructive" onClick={disconnect} size="sm">
<CircleX />
Disconnect
</Button>
)}
@ -396,12 +405,12 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !isConnected && (
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-3">
<SoundOutlined style={{ fontSize: 48 }} />
<Text className="text-lg text-gray-500">Realtime Voice Playground</Text>
<Text className="text-sm text-gray-400 text-center max-w-md">
<Volume2 className="size-12" />
<span className="text-lg text-gray-500">Realtime Voice Playground</span>
<p className="text-sm text-gray-400 text-center max-w-md">
Click <b>Connect</b> to start a realtime session. You can speak using your microphone or type messages.
The AI will respond with voice and text.
</Text>
</p>
</div>
)}
{messages.map((msg, i) => (
@ -433,30 +442,26 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
<div className="border-t border-gray-200 p-3 bg-white">
<div className="flex items-center gap-2">
<Button
shape="circle"
size="large"
type={isRecording ? "primary" : "default"}
danger={isRecording}
icon={isRecording ? <AudioMutedOutlined /> : <AudioOutlined />}
size="icon-lg"
variant={isRecording ? "destructive" : "outline"}
onClick={isRecording ? stopRecording : startRecording}
title={isRecording ? "Stop recording" : "Start recording"}
className={isRecording ? "animate-pulse" : ""}
/>
className={`rounded-full ${isRecording ? "animate-pulse" : ""}`}
>
{isRecording ? <MicOff /> : <Mic />}
</Button>
<Input
placeholder="Type a message or use the mic..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onPressEnter={sendTextMessage}
className="flex-1"
size="large"
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={sendTextMessage}
disabled={!inputText.trim()}
size="large"
onKeyDown={(e) => {
if (e.key === "Enter") sendTextMessage();
}}
className="h-10 flex-1"
/>
<Button size="icon-lg" onClick={sendTextMessage} disabled={!inputText.trim()} aria-label="Send">
<Send />
</Button>
</div>
{isRecording && (
<div className="mt-2 flex items-center gap-2 text-red-500 text-xs">

View file

@ -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(() => {

View file

@ -2,9 +2,12 @@
import NotificationsManager from "@/components/molecules/notifications_manager";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { ClearOutlined, DeleteOutlined, FilePdfOutlined, PlusOutlined } from "@ant-design/icons";
import { Eraser, FileText, Plus, Trash2 } from "lucide-react";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { Button, Input, Select, Tooltip } from "antd";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useEffect, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import ChatImageUpload from "../chat_ui/ChatImageUpload";
@ -692,17 +695,22 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<span className="text-sm font-medium text-gray-600">Virtual Key Source</span>
<Select
value={apiKeySource}
onChange={(value) => setApiKeySource(value as "session" | "custom")}
onValueChange={(value) => setApiKeySource(value as "session" | "custom")}
disabled={disabledPersonalKeyCreation}
className="w-48"
>
<Select.Option value="session" disabled={!canUseSessionKey}>
Current UI Session
</Select.Option>
<Select.Option value="custom">Virtual Key</Select.Option>
<SelectTrigger className="w-48" aria-label="Virtual Key Source">
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="session" disabled={!canUseSessionKey}>
Current UI Session
</SelectItem>
<SelectItem value="custom">Virtual Key</SelectItem>
</SelectContent>
</Select>
{apiKeySource === "custom" && (
<Input.Password
<Input
type="password"
value={customApiKey}
onChange={(event) => setCustomApiKey(event.target.value)}
placeholder="Enter Virtual Key"
@ -712,30 +720,34 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Endpoint</span>
<Select
value={selectedEndpoint}
onChange={(value) => setSelectedEndpoint(value as EndpointIdType)}
className="w-56"
>
{getAvailableEndpoints().map((endpoint) => (
<Select.Option key={endpoint.value} value={endpoint.value}>
{endpoint.label}
</Select.Option>
))}
<Select value={selectedEndpoint} onValueChange={(value) => setSelectedEndpoint(value as EndpointIdType)}>
<SelectTrigger className="w-56" aria-label="Endpoint">
<SelectValue>{endpointConfig.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{getAvailableEndpoints().map((endpoint) => (
<SelectItem key={endpoint.value} value={endpoint.value}>
{endpoint.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button onClick={clearAllChats} disabled={!hasMessages} icon={<ClearOutlined />}>
<Button variant="outline" onClick={clearAllChats} disabled={!hasMessages}>
<Eraser />
Clear All Chats
</Button>
<Tooltip
title={
comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"
}
>
<Button onClick={addComparison} disabled={comparisons.length >= maxComparisons} icon={<PlusOutlined />}>
Add Comparison
</Button>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons}>
<Plus />
Add Comparison
</Button>
</TooltipTrigger>
<TooltipContent>
{comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"}
</TooltipContent>
</Tooltip>
</div>
</div>
@ -807,8 +819,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{isUploadedFilePdf ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center text-white">
<FileText className="size-4" aria-label="file-pdf" />
</div>
) : (
<img
@ -825,8 +837,9 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
onClick={handleRemoveFile}
aria-label="Remove attachment"
>
<DeleteOutlined style={{ fontSize: "12px" }} />
<Trash2 className="size-3" />
</button>
</div>
</div>

View file

@ -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,137 @@ const mockProps = {
apiKey: "test-api-key",
};
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<typeof userEvent.setup>) => {
await user.click(buttonWithIcon("settings"));
await screen.findByText("General Settings");
};
describe("ComparisonPanel", () => {
it("should render", () => {
const { getByTestId } = render(<ComparisonPanel {...mockProps} />);
expect(getByTestId("unified-selector")).toBeInTheDocument();
expect(getByTestId("message-display")).toBeInTheDocument();
it("renders the selector and the transcript", () => {
render(<ComparisonPanel {...mockProps} />);
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(<ComparisonPanel {...mockProps} onRemove={onRemove} />);
const removeButton = container.querySelector('button[class*="text-red-600"]');
expect(removeButton).toBeInTheDocument();
await user.click(removeButton!);
render(<ComparisonPanel {...mockProps} onRemove={onRemove} />);
await user.click(buttonWithIcon("x"));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it("hides the remove control on the last remaining panel", () => {
render(<ComparisonPanel {...mockProps} canRemove={false} />);
expect(() => buttonWithIcon("x")).toThrow();
});
it("keeps the settings out of sight until the gear is used", async () => {
const user = userEvent.setup();
render(<ComparisonPanel {...mockProps} />);
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(<ComparisonPanel {...mockProps} />);
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(<ComparisonPanel {...mockProps} onUpdate={onUpdate} />);
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(
<ComparisonPanel
{...mockProps}
comparison={{ ...mockComparison, applyAcrossModels: true }}
onUpdate={onUpdate}
/>,
);
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(<ComparisonPanel {...mockProps} onUpdate={onUpdate} />);
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(
<ComparisonPanel
{...mockProps}
comparison={{ ...mockComparison, applyAcrossModels: true }}
onUpdate={onUpdate}
/>,
);
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"] });
});
});

View file

@ -1,12 +1,15 @@
import { Settings, X } from "lucide-react";
import { useState } from "react";
import { useId, useState } from "react";
import { ComparisonInstance } from "../CompareUI";
import { MessageDisplay } from "./MessageDisplay";
import { UnifiedSelector } from "./UnifiedSelector";
import TagSelector from "@/components/tag_management/TagSelector";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
import { Checkbox, Divider, Popover, Slider } from "antd";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { SelectorOption, EndpointConfig, isAgentEndpoint, getComparisonSelection } from "../endpoint_config";
interface ComparisonPanelProps {
@ -35,6 +38,8 @@ export function ComparisonPanel({
const isA2AMode = isAgentEndpoint(endpointConfig.id);
const currentSelection = getComparisonSelection(comparison, endpointConfig.id);
const [popoverVisible, setPopoverVisible] = useState(false);
const syncId = useId();
const advancedParamsId = useId();
const handleSyncChange = (checked: boolean) => {
if (checked) {
@ -103,12 +108,18 @@ export function ComparisonPanel({
<div className="space-y-2">
{/* Sync Checkbox */}
<div className="flex items-center gap-2">
<Checkbox checked={comparison.applyAcrossModels} onChange={(e) => handleSyncChange(e.target.checked)}>
<span className="text-xs font-medium">Sync Settings Across Models</span>
</Checkbox>
<Checkbox
id={syncId}
checked={comparison.applyAcrossModels}
onCheckedChange={handleSyncChange}
aria-label="Sync Settings Across Models"
/>
<label htmlFor={syncId} className="cursor-pointer text-xs font-medium">
Sync Settings Across Models
</label>
</div>
<Divider className="border-gray-200" />
<Separator className="my-3" />
{/* General Settings */}
<div>
@ -146,11 +157,14 @@ export function ComparisonPanel({
<div className="space-y-2">
<div className="flex items-center gap-2 pb-1">
<Checkbox
id={advancedParamsId}
checked={comparison.useAdvancedParams}
onChange={(e) => handleAdvancedParamsChange(e.target.checked)}
>
<span className="text-sm font-medium">Use Advanced Parameters</span>
</Checkbox>
onCheckedChange={handleAdvancedParamsChange}
aria-label="Use Advanced Parameters"
/>
<label htmlFor={advancedParamsId} className="cursor-pointer text-sm font-medium">
Use Advanced Parameters
</label>
</div>
<div className="space-y-2 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
<div>
@ -162,8 +176,8 @@ export function ComparisonPanel({
min={0}
max={2}
step={0.01}
value={comparison.temperature}
onChange={(value) => {
value={[comparison.temperature]}
onValueChange={(value) => {
const nextValue = Array.isArray(value) ? value[0] : value;
const clamped = Math.min(2, Math.max(0, Number(nextValue.toFixed(2))));
handleSettingChange("temperature", clamped);
@ -180,8 +194,8 @@ export function ComparisonPanel({
min={1}
max={32768}
step={1}
value={comparison.maxTokens}
onChange={(value) => {
value={[comparison.maxTokens]}
onValueChange={(value) => {
const nextValue = Array.isArray(value) ? value[0] : value;
const clamped = Math.min(32768, Math.max(1, Math.round(nextValue)));
handleSettingChange("maxTokens", clamped);
@ -209,26 +223,29 @@ export function ComparisonPanel({
/>
<div className="flex items-center gap-2">
<Popover
content={settingsContent}
trigger={[]}
open={popoverVisible}
onOpenChange={() => {
// Prevent automatic closing - we control it manually
}}
placement="bottomRight"
destroyTooltipOnHide={false}
>
<button
onClick={(event) => {
event.stopPropagation();
handleTogglePopover();
}}
className={`p-2 rounded-lg transition-colors ${
popoverVisible ? "bg-gray-200 text-gray-700" : "hover:bg-gray-100 text-gray-600"
}`}
>
<Settings size={18} />
</button>
<PopoverTrigger
render={
<button
onClick={(event) => {
event.stopPropagation();
handleTogglePopover();
}}
className={`p-2 rounded-lg transition-colors ${
popoverVisible ? "bg-gray-200 text-gray-700" : "hover:bg-gray-100 text-gray-600"
}`}
>
<Settings size={18} />
</button>
}
/>
<PopoverContent side="bottom" align="end" className="w-auto">
{settingsContent}
</PopoverContent>
</Popover>
</div>
</div>

View file

@ -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(<MessageInput value="" onChange={onChange} onSend={onSend} />);
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(<MessageInput value="" onChange={vi.fn()} onSend={vi.fn()} />);
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(<MessageInput value="" onChange={onChange} onSend={onSend} />);
const button = container.querySelector("button") as HTMLButtonElement;
render(<MessageInput value="" onChange={onChange} onSend={vi.fn()} />);
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 = <div data-testid="upload-component">Upload</div>;
const { container, getByTestId } = render(
render(<MessageInput value="" onChange={vi.fn()} onSend={onSend} />);
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(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} />);
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(
<MessageInput
value=""
onChange={onChange}
onChange={vi.fn()}
onSend={onSend}
hasAttachment={true}
uploadComponent={uploadComponent}
hasAttachment
uploadComponent={<span data-testid="upload-component">Upload</span>}
/>,
);
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(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} disabled />);
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(<MessageInput value="hello" onChange={vi.fn()} onSend={onSend} />);
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);
});
});

View file

@ -1,8 +1,7 @@
import React from "react";
import { Input, Button } from "antd";
import { ArrowUpOutlined } from "@ant-design/icons";
const { TextArea } = Input;
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface MessageInputProps {
value: string;
@ -29,25 +28,25 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
<div className="flex items-center gap-2">
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
{uploadComponent && <div className="shrink-0 mr-2">{uploadComponent}</div>}
<TextArea
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
disabled={disabled}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
resize: "none",
border: "none",
boxShadow: "none",
background: "transparent",
padding: "4px 0",
fontSize: "14px",
lineHeight: "20px",
}}
rows={1}
className="max-h-20 min-h-0 flex-1 resize-none overflow-y-auto border-0 bg-transparent px-0 py-1 text-sm leading-5 shadow-none focus-visible:ring-0"
/>
<Button onClick={onSend} disabled={!canSend} icon={<ArrowUpOutlined />} shape="circle" />
<Button
onClick={onSend}
disabled={!canSend}
size="icon-sm"
variant="outline"
className="rounded-full"
aria-label="Send message"
>
<ArrowUp />
</Button>
</div>
</div>
);

View file

@ -4,158 +4,118 @@ 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];
const promptIsVisible = (text: string) =>
screen.queryByText(text) !== null || screen.queryByPlaceholderText(text) !== null;
const openList = async (user: ReturnType<typeof userEvent.setup>) => {
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(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
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(
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
it("renders a combobox", () => {
render(
<UnifiedSelector
value=""
options={[{ value: "option1", label: "Option One" }]}
loading={false}
config={CHAT}
onChange={vi.fn()}
/>,
);
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(<UnifiedSelector value="" options={[]} loading={false} config={CHAT} onChange={vi.fn()} />);
const { container } = render(
<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />,
);
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(<UnifiedSelector value="" options={[]} loading={false} config={AGENTS} onChange={vi.fn()} />);
expect(promptIsVisible(AGENTS.selectorPlaceholder)).toBe(true);
});
it("swaps the prompt for a loading message while options are in flight", () => {
render(<UnifiedSelector value="" options={[]} loading config={CHAT} onChange={vi.fn()} />);
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(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
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(
<UnifiedSelector value="option1" options={options} loading={false} config={config} onChange={onChange} />,
render(
<UnifiedSelector
value=""
options={[
{ value: "option1", label: "Option One" },
{ value: "option2", label: "Option Two" },
]}
loading={false}
config={CHAT}
onChange={onChange}
/>,
);
const selectedValue = container.querySelector(".ant-select-selection-item");
expect(selectedValue).toHaveTextContent("Option 1");
await openList(user);
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(
<UnifiedSelector
value=""
options={[
{ value: "option1", label: "Option One" },
{ value: "option2", label: "Option Two" },
{ value: "option3", label: "Different" },
]}
loading={false}
config={CHAT}
onChange={vi.fn()}
/>,
);
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
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(<UnifiedSelector value="" options={[]} loading config={CHAT} onChange={vi.fn()} />);
render(<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />);
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(<UnifiedSelector value="" options={[]} loading={false} config={CHAT} onChange={vi.fn()} />);
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
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(
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
);
const placeholder = container.querySelector(".ant-select-selection-placeholder");
expect(placeholder).toHaveTextContent(config.selectorPlaceholder);
expect(await screen.findByText(`No ${CHAT.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument();
});
});

View file

@ -3,7 +3,15 @@
* based on the current endpoint configuration.
*/
import { Select, Spin } from "antd";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { SelectorOption, EndpointConfig } from "../endpoint_config";
interface UnifiedSelectorProps {
@ -14,26 +22,44 @@ interface UnifiedSelectorProps {
onChange: (value: string) => void;
}
const matchesQuery = (option: SelectorOption, query: string): boolean =>
option.label.toLowerCase().includes(query.trim().toLowerCase());
export function UnifiedSelector({ value, options, loading, config, onChange }: UnifiedSelectorProps) {
const selected = options.find((option) => option.value === value) ?? null;
const noun = config.selectorLabel.toLowerCase();
return (
<Select
value={value || undefined}
placeholder={loading ? `Loading ${config.selectorLabel.toLowerCase()}s...` : config.selectorPlaceholder}
onChange={onChange}
loading={loading}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={options}
className="w-48 md:w-64 lg:w-72"
notFoundContent={
loading ? (
<div className="flex items-center justify-center py-2">
<Spin size="small" />
</div>
) : (
`No ${config.selectorLabel.toLowerCase()}s available`
)
}
/>
<Combobox
items={options}
value={selected}
onValueChange={(option: SelectorOption | null) => onChange(option?.value ?? "")}
isItemEqualToValue={(a: SelectorOption, b: SelectorOption) => a.value === b.value}
itemToStringLabel={(option: SelectorOption) => option.label}
filter={matchesQuery}
>
<ComboboxInput
placeholder={loading ? `Loading ${noun}s...` : config.selectorPlaceholder}
className="w-48 md:w-64 lg:w-72"
/>
<ComboboxContent>
<ComboboxEmpty>
{loading ? (
<span aria-busy="true" className="flex items-center justify-center py-2">
<UiLoadingSpinner className="size-4" />
</span>
) : (
`No ${noun}s available`
)}
</ComboboxEmpty>
<ComboboxList>
{(option: SelectorOption) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}

View file

@ -0,0 +1,41 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "@/lib/cva.config";
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max];
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
export { Slider };