refactor(ui): move the model hub and model select onto shadcn primitives

Rebuilds public_model_hub, MakeSkillPublicForm, ModelSelect and the
guardrail LogViewer on the in-repo shadcn layer, so they inherit the
dashboard's design tokens instead of styling themselves through Ant
Design and Tremor.

Public prop signatures are unchanged, so no caller moves. The two
teams e2e steps that reached into antd's Select internals now drive
the combobox through its test id, role and data-slot instead.
This commit is contained in:
Yuneng Jiang 2026-08-14 04:48:49 -07:00
parent 423b791ee0
commit 3a537cce4d
No known key found for this signature in database
8 changed files with 1261 additions and 1223 deletions

View file

@ -47,10 +47,10 @@ test.describe("Proxy Admin - Teams", () => {
// Fill Team Name — the input has id="team_alias"
await dialog.locator("#team_alias").fill(uniqueAlias);
// Select models — the models multi-select is inside the modal
// Click to open dropdown, select "All Proxy Models"
await dialog.locator(".ant-select-selection-overflow").first().click();
await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click();
// Select models — the models multi-select is inside the modal. Its popup is
// portaled to the body, so scope the option lookup to the page, not the dialog.
await dialog.getByTestId("create-team-models-select").getByRole("combobox").click();
await page.getByRole("option", { name: "All Proxy Models", exact: true }).click();
await page.keyboard.press("Escape");
// Submit — click the submit button inside the dialog (not the header button)
@ -191,11 +191,11 @@ test.describe("Proxy Admin - Teams", () => {
const modelsSelect = page.locator("[data-testid='models-select']");
await expect(modelsSelect).toBeVisible({ timeout: 10_000 });
const anthropicTag = modelsSelect
.locator(".ant-select-selection-item")
const anthropicChip = modelsSelect
.locator('[data-slot="combobox-chip"]')
.filter({ hasText: "fake-anthropic-claude" });
await expect(anthropicTag).toBeVisible({ timeout: 5_000 });
await anthropicTag.locator(".ant-select-selection-item-remove").click();
await expect(anthropicChip).toBeVisible({ timeout: 5_000 });
await anthropicChip.locator('[data-slot="combobox-chip-remove"]').click();
await page.getByRole("button", { name: "Save Changes" }).click();

View file

@ -1830,9 +1830,6 @@
"src/components/GuardrailsMonitor/LogViewer.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/HelpLink.test.tsx": {
@ -1845,11 +1842,6 @@
"count": 1
}
},
"src/components/ModelSelect/ModelSelect.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": {
"max-nested-callbacks": {
"count": 12
@ -2341,9 +2333,6 @@
}
},
"src/components/claude_code_plugins/MakeSkillPublicForm.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2982,9 +2971,6 @@
},
"max-lines": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/query_param_input.tsx": {

View file

@ -1,8 +1,9 @@
import { CheckCircleOutlined, CloseOutlined, DownOutlined, WarningOutlined } from "@ant-design/icons";
import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import moment from "moment";
import { Button, Spin } from "antd";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { uiSpendLogsCall } from "@/components/networking";
import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer";
import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns";
@ -13,21 +14,21 @@ const actionConfig: Record<
{ icon: React.ElementType; color: string; bg: string; border: string; label: string }
> = {
blocked: {
icon: CloseOutlined,
icon: X,
color: "text-red-600",
bg: "bg-red-50",
border: "border-red-200",
label: "Blocked",
},
passed: {
icon: CheckCircleOutlined,
icon: CircleCheck,
color: "text-green-600",
bg: "bg-green-50",
border: "border-green-200",
label: "Passed",
},
flagged: {
icon: WarningOutlined,
icon: TriangleAlert,
color: "text-amber-600",
bg: "bg-amber-50",
border: "border-amber-200",
@ -125,8 +126,8 @@ export function LogViewer({
{filters.map((f) => (
<Button
key={f}
type={activeFilter === f ? "primary" : "default"}
size="small"
variant={activeFilter === f ? "default" : "outline"}
size="sm"
onClick={() => setActiveFilter(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
@ -139,8 +140,8 @@ export function LogViewer({
{sampleSizes.map((size) => (
<Button
key={size}
type={sampleSize === size ? "primary" : "default"}
size="small"
variant={sampleSize === size ? "default" : "outline"}
size="sm"
onClick={() => setSampleSize(size)}
>
{size}
@ -154,7 +155,7 @@ export function LogViewer({
{logsLoading && (
<div className="flex items-center justify-center py-12">
<Spin />
<UiLoadingSpinner className="size-5" />
</div>
)}
{!logsLoading && displayLogs.length === 0 && (
@ -182,11 +183,11 @@ export function LogViewer({
</span>
<span className="text-xs text-gray-400">{log.timestamp}</span>
<span className="text-xs text-gray-400">·</span>
{log.model && <span className="text-xs text-gray-500">{log.model}</span>}
{log.model && <span className="min-w-0 text-xs break-words text-gray-500">{log.model}</span>}
</div>
<p className="text-sm text-gray-800 truncate">{log.input_snippet ?? log.input ?? "—"}</p>
</div>
<DownOutlined className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
</button>
);
})}

View file

@ -1,6 +1,6 @@
import type { ProxyModel } from "@/app/(dashboard)/hooks/models/useModels";
import type { Organization } from "@/components/networking";
import { screen, waitFor } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
@ -22,64 +22,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Select: ({
value,
onChange,
options,
"data-testid": dataTestId,
allowClear,
maxTagCount,
maxTagPlaceholder,
mode,
...props
}: any) => {
// Simulate maxTagCount responsive behavior - if value length > 5, call maxTagPlaceholder
const shouldShowPlaceholder = maxTagCount === "responsive" && Array.isArray(value) && value.length > 5;
const visibleValues = shouldShowPlaceholder ? value.slice(0, 5) : value;
const omittedValues = shouldShowPlaceholder ? value.slice(5).map((v: string) => ({ value: v, label: v })) : [];
return (
<div data-testid={dataTestId || "model-select"}>
<select
multiple={mode === "multiple"}
role="listbox"
value={visibleValues}
onChange={(e) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(mode === "multiple" ? selectedValues : selectedValues[0]);
}}
{...props}
>
{options?.map((group: any) => (
<optgroup
key={group.label?.props?.children || group.title}
label={group.title || group.label?.props?.children}
>
{group.options?.map((option: any) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{typeof option.label === "string" ? option.label : option.label?.props?.children}
</option>
))}
</optgroup>
))}
</select>
{shouldShowPlaceholder && maxTagPlaceholder && (
<div data-testid="max-tag-placeholder">{maxTagPlaceholder(omittedValues)}</div>
)}
</div>
);
},
Skeleton: {
Input: ({ active, block }: any) => <div data-testid="skeleton-input" data-active={active} data-block={block} />,
},
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
};
});
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
@ -108,6 +50,14 @@ const createMockOrganization = (models: string[]): Organization => ({
members: null,
});
const openModelList = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getAllByRole("combobox")[0]);
await screen.findByRole("listbox");
};
const expectOffered = (label: string) => expect(screen.queryAllByText(label).length).toBeGreaterThan(0);
const expectNotOffered = (label: string) => expect(screen.queryAllByText(label)).toHaveLength(0);
describe("ModelSelect", () => {
const mockProxyModels: ProxyModel[] = [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
@ -138,21 +88,26 @@ describe("ModelSelect", () => {
} as any);
});
it("should render with all option groups", async () => {
it("should offer every model and wildcard under its group heading", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
expect(screen.getByText("All Openai models")).toBeInTheDocument();
expect(screen.getByText("All Anthropic models")).toBeInTheDocument();
});
await openModelList(user);
expectOffered("Wildcard Options");
expectOffered("gpt-4");
expectOffered("claude-3");
expectOffered("All Openai models");
expectOffered("All Anthropic models");
});
it("should show skeleton loader when any data is loading", () => {
it("should offer nothing to select while any dependency is loading", () => {
const { unmount: unmountReady } = renderWithProviders(<ModelSelect onChange={mockOnChange} context="user" />);
expect(screen.getAllByRole("combobox")).toHaveLength(1);
unmountReady();
const loadingScenarios = [
{ hook: mockUseAllProxyModels, context: "user" as const },
{ hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } },
@ -168,30 +123,24 @@ describe("ModelSelect", () => {
const { unmount } = renderWithProviders(<ModelSelect onChange={mockOnChange} context={context} {...props} />);
expect(screen.getByTestId("skeleton-input")).toBeInTheDocument();
expect(screen.queryAllByRole("combobox")).toHaveLength(0);
unmount();
});
});
it("should handle model selection and onChange", async () => {
it("should report the picked model to onChange", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
await openModelList(user);
await user.click(screen.getAllByText("gpt-4")[0]);
const select = screen.getByRole("listbox");
await user.selectOptions(select, "gpt-4");
expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]);
await user.selectOptions(select, ["gpt-4", "claude-3"]);
expect(mockOnChange).toHaveBeenCalled();
});
it("should handle special options correctly", async () => {
it("should offer both special options when they are enabled", async () => {
const user = userEvent.setup();
mockUseOrganization.mockReturnValue({
data: createMockOrganization(["all-proxy-models"]),
@ -207,33 +156,32 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
expect(screen.getByText("No Default Models")).toBeInTheDocument();
});
await openModelList(user);
const select = screen.getByRole("listbox");
await user.selectOptions(select, ["all-proxy-models", "no-default-models"]);
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
expectOffered("Special Options");
expectOffered("All Proxy Models");
expectOffered("No Default Models");
});
it("should disable models when special option is selected", async () => {
it("should replace an existing selection when a special option is picked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
value={["gpt-4"]}
context="user"
options={{ showAllProxyModelsOverride: true }}
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
/>,
);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled();
expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled();
});
await openModelList(user);
await user.click(screen.getAllByText("No Default Models")[0]);
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
});
it("should filter models based on context", async () => {
it("should filter the offered models by context", async () => {
const testCases = [
{
name: "user context with includeUserModels",
@ -340,6 +288,7 @@ describe("ModelSelect", () => {
];
for (const testCase of testCases) {
const user = userEvent.setup();
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
@ -350,14 +299,9 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
testCase.expectedVisible.forEach((model) => {
expect(screen.getByText(model)).toBeInTheDocument();
});
testCase.expectedHidden.forEach((model) => {
expect(screen.queryByText(model)).not.toBeInTheDocument();
});
});
await openModelList(user);
testCase.expectedVisible.forEach(expectOffered);
testCase.expectedHidden.forEach(expectNotOffered);
unmount();
vi.clearAllMocks();
@ -368,7 +312,7 @@ describe("ModelSelect", () => {
}
});
it("should show All Proxy Models option based on conditions", async () => {
it("should offer All Proxy Models only when the context allows it", async () => {
const testCases = [
{
name: "when showAllProxyModelsOverride is true",
@ -426,6 +370,7 @@ describe("ModelSelect", () => {
];
for (const testCase of testCases) {
const user = userEvent.setup();
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
@ -436,14 +381,13 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
if (testCase.shouldShow) {
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
} else {
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
expect(screen.getByText("No Default Models")).toBeInTheDocument();
}
});
await openModelList(user);
if (testCase.shouldShow) {
expectOffered("All Proxy Models");
} else {
expectNotOffered("All Proxy Models");
expectOffered("No Default Models");
}
unmount();
vi.clearAllMocks();
@ -454,27 +398,6 @@ describe("ModelSelect", () => {
}
});
it("should deduplicate models with same id", async () => {
const duplicateModels: ProxyModel[] = [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
];
mockUseAllProxyModels.mockReturnValue({
data: { data: duplicateModels },
isLoading: false,
} as any);
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
const gpt4Options = screen.getAllByText("gpt-4");
expect(gpt4Options.length).toBeGreaterThan(0);
});
});
it("should use custom dataTestId when provided", async () => {
renderWithProviders(
<ModelSelect
@ -485,12 +408,11 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
});
expect(await screen.findByTestId("custom-test-id")).toBeInTheDocument();
});
it("should return all proxy models for team context when organization has empty models array", async () => {
const user = userEvent.setup();
mockUseTeam.mockReturnValue({
data: { team_id: "team-1", team_alias: "Test Team", models: [] },
isLoading: false,
@ -503,52 +425,66 @@ describe("ModelSelect", () => {
renderWithProviders(<ModelSelect onChange={mockOnChange} context="team" teamID="team-1" organizationID="org-1" />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
await openModelList(user);
expectOffered("gpt-4");
expectOffered("claude-3");
});
it("should disable No Default Models when all-proxy-models is selected", async () => {
mockUseOrganization.mockReturnValue({
data: createMockOrganization(["all-proxy-models"]),
isLoading: false,
} as any);
it("should not offer a special options group when includeSpecialOptions is omitted", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
await openModelList(user);
expectNotOffered("Special Options");
expectNotOffered("All Proxy Models");
expectNotOffered("No Default Models");
expectOffered("Models");
});
it("should mark models and wildcards unselectable while a special option is selected", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
context="organization"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
context="user"
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
/>,
);
await waitFor(() => {
const noDefaultOption = screen.getByRole("option", { name: "No Default Models" });
expect(noDefaultOption).toBeDisabled();
});
await openModelList(user);
expect(screen.getByRole("option", { name: "gpt-4" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "All Openai models" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "No Default Models" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "All Proxy Models" })).not.toHaveAttribute("aria-disabled", "true");
});
it("should not render an empty optgroup when includeSpecialOptions is omitted", async () => {
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
it("should list a duplicated proxy model only once", async () => {
const user = userEvent.setup();
mockUseAllProxyModels.mockReturnValue({
data: {
data: [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
],
},
isLoading: false,
} as any);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
const optgroups = document.querySelectorAll("optgroup");
// Wildcard Options + Models — no blank leading group
expect(optgroups.length).toBe(2);
optgroups.forEach((g) => {
expect(g.getAttribute("label")).toBeTruthy();
});
await openModelList(user);
expect(screen.getAllByRole("option", { name: "gpt-4" })).toHaveLength(1);
});
it("should render maxTagPlaceholder when many items are selected", async () => {
// Create many models to trigger maxTagCount responsive behavior
const manyModels: ProxyModel[] = Array.from({ length: 20 }, (_, i) => ({
it("should collapse selections past the chip limit into a labelled overflow count", async () => {
const manyModels: ProxyModel[] = Array.from({ length: 8 }, (_, i) => ({
id: `model-${i}`,
object: "model",
created: 1234567890,
@ -560,22 +496,18 @@ describe("ModelSelect", () => {
isLoading: false,
} as any);
const selectedValues = manyModels.slice(0, 10).map((m) => m.id);
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={selectedValues}
value={manyModels.map((m) => m.id)}
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
// Verify maxTagPlaceholder is rendered with omitted values
expect(screen.getByTestId("max-tag-placeholder")).toBeInTheDocument();
expect(screen.getByText(/\+5 more/)).toBeInTheDocument();
});
expect(await screen.findByText("+3 more")).toBeInTheDocument();
expect(screen.getByLabelText("model-0")).toBeInTheDocument();
expect(screen.getByLabelText("model-4")).toBeInTheDocument();
expect(screen.queryByLabelText("model-5")).not.toBeInTheDocument();
});
});

View file

@ -2,7 +2,22 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { Select, Skeleton, Tooltip } from "antd";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Organization, Team } from "../networking";
import { splitWildcardModels } from "./modelUtils";
@ -21,6 +36,8 @@ export const MODEL_SENTINEL_OPTIONS = [
MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE,
] as const;
const MAX_VISIBLE_MODEL_CHIPS = 5;
export interface ModelSelectProps {
teamID?: string;
organizationID?: string;
@ -37,6 +54,17 @@ export interface ModelSelectProps {
style?: React.CSSProperties;
}
type ModelOption = {
label: string;
value: string;
disabled?: boolean;
};
type ModelOptionGroup = {
label: string;
items: ModelOption[];
};
type FilterContextArgs = {
allProxyModels: string[];
selectedTeam?: Team;
@ -109,10 +137,11 @@ export const ModelSelect = (props: ModelSelectProps) => {
showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global";
if (isLoading) {
return <Skeleton.Input active block />;
return <Skeleton className="h-9 w-full" />;
}
const handleChange = (values: string[]) => {
const handleChange = (selected: ModelOption[]) => {
const values = selected.map((option) => option.value);
const specialValues = values.filter(isSpecialOption);
let finalValues: string[];
@ -133,85 +162,122 @@ export const ModelSelect = (props: ModelSelectProps) => {
});
const { wildcard, regular } = splitWildcardModels(filteredModels);
return (
<Select
data-testid={dataTestId}
value={value}
onChange={handleChange}
style={style}
options={[
...(includeSpecialOptions
? [
{
label: <span>Special Options</span>,
title: "Special Options",
options: [
...(shouldShowAllProxyModels
? [
{
label: <span>All Proxy Models</span>,
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some(
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
),
key: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
},
]
: []),
{
label: <span>No Default Models</span>,
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
key: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
},
],
},
]
: []),
...(wildcard.length > 0
? [
{
label: <span>Wildcard Options</span>,
title: "Wildcard Options",
options: wildcard.map((model) => {
const provider = model.replace("/*", "");
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
return {
label: <span>{`All ${capitalizedProvider} models`}</span>,
value: model,
disabled: hasSpecialOptionSelected,
};
}),
const groups: ModelOptionGroup[] = [
...(includeSpecialOptions
? [
{
label: "Special Options",
items: [
...(shouldShowAllProxyModels
? [
{
label: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.label,
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some(
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
),
},
]
: []),
{
label: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.label,
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
},
]
: []),
{
label: <span>Models</span>,
title: "Models",
options: regular.map((model) => ({
label: <span>{model}</span>,
value: model,
disabled: hasSpecialOptionSelected,
})),
},
]}
mode="multiple"
placeholder="Select Models"
allowClear
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<Tooltip
styles={{ root: { pointerEvents: "none" } }}
title={omittedValues.map(({ value }) => value).join(", ")}
>
<span>+{omittedValues.length} more</span>
</Tooltip>
)}
/>
],
},
]
: []),
...(wildcard.length > 0
? [
{
label: "Wildcard Options",
items: wildcard.map((model) => {
const provider = model.replace("/*", "");
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
return {
label: `All ${capitalizedProvider} models`,
value: model,
disabled: hasSpecialOptionSelected,
};
}),
},
]
: []),
{
label: "Models",
items: regular.map((model) => ({
label: model,
value: model,
disabled: hasSpecialOptionSelected,
})),
},
];
const optionsByValue = new Map(groups.flatMap((group) => group.items).map((option) => [option.value, option]));
const selectedOptions = value.map((v) => optionsByValue.get(v) ?? { label: v, value: v });
const overflowOptions = selectedOptions.slice(MAX_VISIBLE_MODEL_CHIPS);
return (
<TooltipProvider>
<Combobox
multiple
items={groups}
value={selectedOptions}
onValueChange={handleChange}
isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value}
itemToStringLabel={(option: ModelOption) => option.label}
>
<ComboboxChips data-testid={dataTestId} style={style} className="w-full">
<ComboboxValue>
{(selected: ModelOption[]) => (
<>
{selected.slice(0, MAX_VISIBLE_MODEL_CHIPS).map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
{overflowOptions.length > 0 && (
<Tooltip>
<TooltipTrigger
render={<span className="px-1 text-xs text-muted-foreground" />}
>{`+${overflowOptions.length} more`}</TooltipTrigger>
<TooltipContent>{overflowOptions.map((option) => option.value).join(", ")}</TooltipContent>
</Tooltip>
)}
</>
)}
</ComboboxValue>
<ComboboxChipsInput
placeholder="Select Models"
aria-label="Select Models"
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(group: ModelOptionGroup) => (
<ComboboxGroup key={group.label} items={group.items}>
<ComboboxLabel>{group.label}</ComboboxLabel>
<ComboboxCollection>
{(option: ModelOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
<span className="min-w-0 break-words">{option.label}</span>
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</TooltipProvider>
);
};

View file

@ -1,11 +1,15 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/cva.config";
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
const { Step } = Steps;
const STEP_TITLES = ["Select Skills", "Confirm"];
interface MakeSkillPublicFormProps {
visible: boolean;
@ -25,12 +29,10 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedSkills(new Set());
form.resetFields();
onClose();
};
@ -106,52 +108,44 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const renderStep1 = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Skills to Publish</Title>
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={skillsList.length === 0}
>
<h3 className="text-lg font-semibold">Select Skills to Publish</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={skillsList.length === 0}
/>
Select All ({skillsList.length})
</Checkbox>
</label>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{skillsList.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No skills registered yet.</Text>
<p>No skills registered yet.</p>
</div>
) : (
skillsList.map((skill) => (
<div key={skill.name} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
aria-label={skill.name}
checked={selectedSkills.has(skill.name)}
onChange={(e) => handleSkillSelection(skill.name, e.target.checked)}
onCheckedChange={(checked) => handleSkillSelection(skill.name, checked === true)}
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Text className="font-medium font-mono text-sm">{skill.name}</Text>
{skill.enabled && (
<Badge color="green" size="xs">
Public
</Badge>
)}
<p className="font-medium font-mono text-sm break-words">{skill.name}</p>
{skill.enabled && <Badge variant="secondary">Public</Badge>}
</div>
{skill.description && (
<Text className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</Text>
)}
{skill.description && <p className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</p>}
</div>
{skill.domain && (
<Badge color="blue" size="xs">
{skill.domain}
</Badge>
)}
{skill.domain && <Badge variant="outline">{skill.domain}</Badge>}
</div>
))
)}
@ -160,9 +154,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
{selectedSkills.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</p>
</div>
)}
</div>
@ -170,29 +164,25 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const renderStep2 = () => (
<div className="space-y-4">
<Title>Confirm Publish to Skill Hub</Title>
<h3 className="text-lg font-semibold">Confirm Publish to Skill Hub</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab. Skills not in the
list below will be unpublished.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Skills to be published:</Text>
<p className="font-medium">Skills to be published:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedSkills).map((name) => {
const skill = skillsList.find((s) => s.name === name);
return (
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<Text className="font-mono text-sm">{name}</Text>
{skill?.domain && (
<Badge color="blue" size="xs">
{skill.domain}
</Badge>
)}
<div key={name} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-sm">
<p className="font-mono text-sm min-w-0 break-words">{name}</p>
{skill?.domain && <Badge variant="outline">{skill.domain}</Badge>}
</div>
);
})}
@ -201,49 +191,68 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</p>
</div>
</div>
);
return (
<Modal
title="Publish to Skill Hub"
open={visible}
onCancel={handleClose}
footer={null}
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Skills" />
<Step title="Confirm" />
</Steps>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Publish to Skill Hub</DialogTitle>
</DialogHeader>
{currentStep === 0 ? renderStep1() : renderStep2()}
<div>
<ol className="mb-6 flex items-center gap-6">
{STEP_TITLES.map((title, index) => (
<li
key={title}
className="flex items-center gap-2"
aria-current={currentStep === index ? "step" : undefined}
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs",
currentStep === index
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
</span>
</li>
))}
</ol>
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
Publish to Hub
</Button>
)}
{currentStep === 0 ? renderStep1() : renderStep2()}
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Publish to Hub
</Button>
)}
</div>
</div>
</div>
</Form>
</Modal>
</DialogContent>
</Dialog>
);
};

View file

@ -226,4 +226,19 @@ describe("public hub MCP details modal", () => {
await screen.findByText("Server Overview");
expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument();
});
it("closes the server details modal from its close control", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
render(<PublicModelHub />);
fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
fireEvent.click(await screen.findByRole("button", { name: "exa_test" }));
await screen.findByText("Server Overview");
fireEvent.click(screen.getByRole("button", { name: /close/i }));
await waitFor(() => expect(screen.queryByText("Server Overview")).not.toBeInTheDocument());
});
});

File diff suppressed because it is too large Load diff