feat(ui)!: redesign public AI Hub as a catalog (#50153)

This commit is contained in:
tin-berri 2026-11-04 20:29:38 -08:00 • committed by GitHub
parent a1ffb06e28
commit 2ffbdf0d7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1422 additions and 1103 deletions

View file

@ -1,8 +1,8 @@
import { test, expect, type APIRequestContext } from "@playwright/test";
import { test, expect } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
import { Page } from "../../fixtures/pages";
import { masterKey } from "../../helpers/traffic";
import { masterKey, uniqueSuffix } from "../../helpers/traffic";
test.describe("AI Hub (internal admin view)", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
@ -62,103 +62,93 @@ test.describe("AI Hub (internal admin view)", () => {
});
test.describe("Public model hub (/ui/model_hub_table)", () => {
// No storageState — the public page is reached anonymously with a `key` query param.
test("Public model_hub_table loads and renders the Models tab", async ({ page }) => {
// The page expects the proxy key as the `key` query param. Use the master
// key the e2e runner already exports — this matches what the AI Hub copy
// button hands out.
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
// Dismiss the feedback popup before asserting on the tab, so a popup
// race can't briefly mask the tab while we're evaluating visibility.
test("shows the Model Hub and Skills Hub catalogs", async ({ page }) => {
await page.goto("/ui/model_hub_table");
await dismissFeedbackPopup(page);
await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("tab", { name: "Model Hub", exact: true }),
).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Search models", exact: true }),
).toBeVisible();
await expect(page.getByRole("tab")).toHaveCount(2);
await page.getByRole("tab", { name: "Skills Hub", exact: true }).click();
await expect(
page.getByRole("textbox", { name: "Search skills", exact: true }),
).toBeVisible();
});
test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => {
const suffix = `${Date.now()}`;
const agentName = `e2e-public-agent-${suffix}`;
const mcpServerName = `e2e_public_mcp_${suffix}`;
test("finds a published skill and opens its installation instructions", async ({
page,
request,
}) => {
const skillName = `e2e-catalog-${uniqueSuffix()}`;
const domain = `catalog-${uniqueSuffix()}`;
const auth = { Authorization: `Bearer ${masterKey()}` };
const publicMcpServerIds = async (api: APIRequestContext): Promise<string[]> => {
const res = await api.get("/public/mcp_hub");
expect(res.ok(), `public mcp_hub read failed (${res.status()}): ${await res.text()}`).toBe(true);
const servers: { server_id: string }[] = await res.json();
return servers.map((server) => server.server_id);
};
const seedPublicEntries = async (
api: APIRequestContext,
priorMcpIds: string[],
): Promise<{ agentId: string; serverId: string }> => {
const agentRes = await api.post("/v1/agents", {
headers: auth,
data: {
agent_name: agentName,
agent_card_params: {
name: agentName,
description: "E2E public agent",
version: "1.0.0",
url: "http://127.0.0.1:9999/",
capabilities: {},
skills: [],
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
},
},
});
expect(agentRes.ok(), `agent create failed (${agentRes.status()}): ${await agentRes.text()}`).toBe(true);
const agentId = (await agentRes.json()).agent_id as string;
const serverRes = await api.post("/v1/mcp/server", {
headers: auth,
data: {
server_name: mcpServerName,
url: "http://127.0.0.1:9999/mcp",
transport: "http",
description: "E2E public MCP server",
},
});
expect(serverRes.ok(), `mcp server create failed (${serverRes.status()}): ${await serverRes.text()}`).toBe(true);
const serverId = (await serverRes.json()).server_id as string;
const agentPublicRes = await api.post(`/v1/agents/${agentId}/make_public`, { headers: auth });
expect(agentPublicRes.ok(), `agent make_public failed: ${await agentPublicRes.text()}`).toBe(true);
const mcpPublicRes = await api.post("/v1/mcp/make_public", {
headers: auth,
data: { mcp_server_ids: [...priorMcpIds, serverId] },
});
expect(mcpPublicRes.ok(), `mcp make_public failed: ${await mcpPublicRes.text()}`).toBe(true);
return { agentId, serverId };
};
const priorMcpIds = await publicMcpServerIds(request);
const { agentId, serverId } = await seedPublicEntries(request, priorMcpIds);
const created = await request.post("/claude-code/plugins", {
headers: auth,
data: {
name: skillName,
source: { source: "github", repo: "example/catalog-skills" },
description: "Catalog browser test skill",
domain,
version: "1.0.0",
},
});
expect(
created.ok(),
`skill registration failed (${created.status()}): ${await created.text()}`,
).toBe(true);
try {
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
await page.goto("/ui/model_hub_table?tab=skills");
await dismissFeedbackPopup(page);
await page.getByRole("checkbox", { name: domain, exact: true }).check();
await page
.getByRole("textbox", { name: "Search skills", exact: true })
.fill(skillName);
await page.getByRole("link").filter({ hasText: skillName }).click();
const agentsTab = page.getByRole("tab", { name: /^Agents/ });
await expect(agentsTab).toBeVisible({ timeout: 15_000 });
await agentsTab.click();
await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 });
await expect(page.getByText("E2E public agent").first()).toBeVisible();
const mcpTab = page.getByRole("tab", { name: /^MCP/ });
await expect(mcpTab).toBeVisible();
await mcpTab.click();
await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 });
await expect(page.getByText("E2E public MCP server").first()).toBeVisible();
const details = page.getByRole("dialog");
await expect(
details.getByRole("heading", { name: skillName, exact: true }),
).toBeVisible();
await expect(
details.getByText(`/plugin install ${skillName}@litellm`, {
exact: true,
}),
).toBeVisible();
await expect(page).toHaveURL(new RegExp(`open=${skillName}`));
await page.reload();
await expect(
page
.getByRole("dialog")
.getByRole("heading", { name: skillName, exact: true }),
).toBeVisible();
} finally {
await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } });
await request.delete(`/v1/agents/${agentId}`, { headers: auth });
await request.delete(`/v1/mcp/server/${serverId}`, { headers: auth });
const deleted = await request.delete(
`/claude-code/plugins/${skillName}`,
{ headers: auth },
);
expect(deleted.ok(), `skill cleanup failed (${deleted.status()})`).toBe(
true,
);
}
});
test("retired catalog links return to Models and clear the old selection", async ({
page,
}) => {
for (const tab of ["agents", "mcp"]) {
await page.goto(
`/ui/model_hub_table?tab=${tab}&open=retired-entry&key=preserved`,
);
await dismissFeedbackPopup(page);
await expect(
page.getByRole("tab", { name: "Model Hub", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(page).toHaveURL(/model_hub_table\?key=preserved$/);
await expect(page.getByRole("dialog")).toHaveCount(0);
}
});
});

View file

@ -29,6 +29,7 @@ interface NavbarProps {
onToggleSidebar?: () => void;
deploymentHealth?: React.ReactNode;
deploymentHealthLink?: React.ReactNode;
publicNavigation?: React.ReactNode;
}
const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
@ -40,6 +41,7 @@ const Navbar: React.FC<NavbarProps> = ({
onToggleSidebar,
deploymentHealth,
deploymentHealthLink,
publicNavigation,
}) => {
const { t } = useTranslation();
const baseUrl = getProxyBaseUrl();
@ -132,6 +134,8 @@ const Navbar: React.FC<NavbarProps> = ({
</div>
</div>
{publicNavigation}
{!isPublicPage && (
<div className="ml-4 flex shrink-0 items-center border-l border-border pl-4">
<ViewSwitcher />

View file

@ -1,93 +0,0 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentCard } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription } from "@/components/ui/field";
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item";
import { agentCapabilities } from "./hubFormat";
import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
import { agentSnippets } from "./hubSnippets";
import { InspectorHeader } from "./InspectorHeader";
const modes = (agent: AgentCard) => {
const input = (agent.defaultInputModes ?? []).join(", ");
const output = (agent.defaultOutputModes ?? []).join(", ");
return input || output ? `${input || "-"} / ${output || "-"}` : null;
};
export function AgentInspector({ agent }: { agent: AgentCard }) {
const { t } = useTranslation();
const capabilities = agentCapabilities(agent);
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader
title={agent.name}
copyLabel={t("publicModelHub.agentInspector.copyName")}
description={agent.description}
/>
<Facts
groups={[
[
{ label: t("publicModelHub.agentInspector.facts.version"), value: agent.version },
{ label: t("publicModelHub.agentInspector.facts.owner"), value: agent.provider?.organization },
{
label: t("publicModelHub.agentInspector.facts.protocol"),
value: agent.protocolVersion && `A2A ${agent.protocolVersion}`,
},
],
[
{ label: t("publicModelHub.agentInspector.facts.inputOutput"), wide: true, value: modes(agent) },
{
label: t("publicModelHub.agentInspector.facts.capabilities"),
wide: true,
value: capabilities.length > 0 && <Badges items={capabilities} />,
},
],
]}
/>
<EndpointField id={`agent-endpoint-${agent.name}`} value={agent.url} />
{agent.documentationUrl && (
<Button
variant="link"
nativeButton={false}
className="h-auto self-start px-0"
render={<a href={agent.documentationUrl} target="_blank" rel="noopener noreferrer" />}
>
{t("publicModelHub.agentInspector.documentation")}
<ExternalLink />
</Button>
)}
{(agent.skills ?? []).length > 0 && (
<PaneSection title={t("publicModelHub.agentInspector.skills")}>
<ItemGroup className="gap-2">
{agent.skills.map((skill) => (
<Item key={skill.id} variant="outline" size="sm" role="listitem">
<ItemContent>
<ItemTitle>{skill.name}</ItemTitle>
<ItemDescription>{skill.description}</ItemDescription>
</ItemContent>
<ItemActions>
<Badges items={skill.tags ?? []} />
</ItemActions>
</Item>
))}
</ItemGroup>
</PaneSection>
)}
<PaneSection title={t("publicModelHub.agentInspector.callAgent")}>
{agentSnippets(agent).map((snippet, index) => (
<Field key={snippet.title}>
<FieldDescription>
{t("publicModelHub.agentInspector.step", { step: index + 1, title: snippet.title })}
</FieldDescription>
<CodeBlock code={snippet.code} />
</Field>
))}
</PaneSection>
</div>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
export function CatalogDetails({
name,
loading = false,
onClose,
children,
}: {
name: string | null;
loading?: boolean;
onClose: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<Dialog open={name !== null} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[90dvh] gap-0 overflow-y-auto p-0 pt-8 sm:max-w-3xl" aria-describedby={undefined}>
<DialogTitle className="sr-only">{t("publicModelHub.masterDetail.details")}</DialogTitle>
{loading ? (
<div className="flex flex-col gap-6 p-6" aria-busy="true">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
children
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useIsNarrow } from "./useIsNarrow";
export function CatalogFilters({
children,
count,
onClear,
}: {
children: ReactNode;
count: number;
onClear: () => void;
}) {
const { t } = useTranslation();
const narrow = useIsNarrow();
const [expanded, setExpanded] = useState(false);
return (
<aside data-slot="catalog-filters">
<Collapsible open={!narrow || expanded} onOpenChange={setExpanded}>
<div className="mb-5 flex items-center justify-between gap-2">
<h2 className="hidden text-sm font-semibold lg:block">{t("publicModelHub.catalog.filters")}</h2>
<CollapsibleTrigger render={<Button variant="outline" className="lg:hidden" />}>
{t("publicModelHub.catalog.filters")}
{count > 0 && <span className="tabular-nums">({count})</span>}
<ChevronDown />
</CollapsibleTrigger>
{count > 0 && (
<Button variant="ghost" size="sm" onClick={onClear}>
{t("publicModelHub.catalog.clearFilters")}
</Button>
)}
</div>
<CollapsibleContent className="flex flex-col gap-5">{children}</CollapsibleContent>
</Collapsible>
</aside>
);
}

View file

@ -1,93 +0,0 @@
"use client";
import { CirclePlus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "@/components/ui/combobox";
import { cn } from "@/lib/cva.config";
import { firstPlus } from "./hubFormat";
import { ProviderAvatar } from "./HubParts";
interface FilterOption {
value: string;
label: string;
provider?: string;
}
export function FilterChip({
label,
noun,
options,
value,
onChange,
}: {
label: string;
noun: string;
options: FilterOption[];
value: string[];
onChange: (value: string[]) => void;
}) {
const { t } = useTranslation();
const byValue = new Map(options.map((option) => [option.value, option]));
const selected = value.map((v) => byValue.get(v)?.label ?? v);
const active = value.length > 0;
return (
<ButtonGroup>
<Combobox<FilterOption, true>
multiple
items={options}
value={options.filter((option) => value.includes(option.value))}
onValueChange={(next) => onChange(next.map((option) => option.value))}
itemToStringLabel={(option) => option.label}
isItemEqualToValue={(a, b) => a.value === b.value}
>
<ComboboxTrigger
render={
<Button
variant="outline"
size="sm"
className={cn("[&>svg:last-child]:hidden", !active && "border-dashed text-muted-foreground")}
/>
}
>
{!active && <CirclePlus />}
{label}
{active && <span className="font-normal text-muted-foreground">{firstPlus(selected)}</span>}
</ComboboxTrigger>
<ComboboxContent className="w-auto min-w-60">
<ComboboxInput showTrigger={false} placeholder={t("publicModelHub.filterChip.search", { noun })} />
<ComboboxEmpty>{t("publicModelHub.filterChip.empty", { noun })}</ComboboxEmpty>
<ComboboxList>
{(option: FilterOption) => (
<ComboboxItem key={option.value} value={option}>
{option.provider && <ProviderAvatar provider={option.provider} />}
<span className="whitespace-nowrap">{option.label}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{active && (
<Button
variant="outline"
size="icon-sm"
aria-label={t("publicModelHub.filterChip.clear", { label: label.toLowerCase() })}
onClick={() => onChange([])}
>
<X />
</Button>
)}
</ButtonGroup>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@ -18,7 +18,8 @@ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/
import { gatewayUrl, usefulLinkList } from "./hubFormat";
import { CopyIconButton } from "./HubParts";
import { StatusBadge } from "./HubStatus";
import type { HubTab } from "./useHubUrlState";
import styles from "./catalog.module.css";
import type { HubInfo } from "./useHubData";
export type HubStatus = "loading" | "operational" | "degraded" | "unavailable";
@ -59,7 +60,7 @@ function BaseUrlField() {
const { t } = useTranslation();
const url = gatewayUrl();
return (
<Field className="w-full gap-1.5 sm:w-[420px]">
<Field className="w-full gap-1.5 sm:w-[360px]">
<InputGroup className="h-8">
<InputGroupAddon>
<InputGroupText className="text-xs font-medium">{t("publicModelHub.baseUrlField.label")}</InputGroupText>
@ -75,31 +76,33 @@ function BaseUrlField() {
<CopyIconButton value={url} label={t("publicModelHub.baseUrlField.copy")} className="size-6" />
</InputGroupAddon>
</InputGroup>
<FieldDescription id="hub-base-url-hint" className="text-xs text-balance">
<FieldDescription id="hub-base-url-hint" className="hidden text-xs text-balance sm:block">
{t("publicModelHub.baseUrlField.hint")}
</FieldDescription>
</Field>
);
}
export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) {
export function HubHeader({ info, status, tab }: { info: HubInfo | undefined; status: HubStatus; tab: HubTab }) {
const { t } = useTranslation();
return (
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="flex items-center gap-2 text-xl leading-7 font-semibold">
<LayoutGrid className="size-5" />
{t("publicModelHub.header.title")}
</h1>
{status === "operational" && <StatusBadge tone="success" label={t("publicModelHub.header.operational")} />}
</div>
<p className="text-sm text-muted-foreground">
{info?.custom_docs_description || t("publicModelHub.header.description")}
<header className={styles.header}>
<div>
<p className={styles.eyebrow}>{info?.docs_title || t("publicModelHub.catalog.eyebrow")}</p>
<h1 className={styles.title}>
{t(tab === "skills" ? "publicModelHub.catalog.skillsHub" : "publicModelHub.catalog.modelHub")}
</h1>
<p className={styles.description}>
{info?.custom_docs_description ||
t(
tab === "skills"
? "publicModelHub.catalog.skillsDescription"
: "publicModelHub.catalog.modelsDescription",
)}
</p>
</div>
{status !== "unavailable" && (
<div className="flex flex-wrap items-start gap-2">
<div className={styles.connection}>
<BaseUrlField />
<LinksMenu info={info} />
</div>

View file

@ -1,235 +0,0 @@
"use client";
import * as React from "react";
import { Item, ItemContent, ItemGroup, ItemMedia } from "@/components/ui/item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { NotListed } from "./HubEmpty";
import { InSheet } from "./InspectorHeader";
import { useOpenHref } from "./useHubUrlState";
import { useIsNarrow } from "./useIsNarrow";
function ListSkeleton() {
return (
<ItemGroup className="gap-0.5 p-3" aria-busy="true">
{[0, 1, 2].map((i) => (
<div key={i} role="listitem">
<Item size="sm">
<ItemMedia variant="icon" className="size-5">
<Skeleton className="size-5 rounded-full" />
</ItemMedia>
<ItemContent className="gap-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</ItemContent>
</Item>
</div>
))}
</ItemGroup>
);
}
function DetailSkeleton() {
return (
<div className="flex flex-col gap-6 p-6" aria-busy="true">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
<Skeleton className="h-40 w-full" />
</div>
);
}
const isPlainClick = (event: React.MouseEvent) => {
const opensElsewhere = event.metaKey || event.ctrlKey || event.shiftKey;
return event.button === 0 && !opensElsewhere;
};
function Rows<T>({
rows,
activeKey,
explicit,
getKey,
onSelect,
renderItem,
}: {
rows: T[];
activeKey: string | undefined;
explicit: boolean;
getKey: (row: T) => string;
onSelect: (key: string) => void;
renderItem: (row: T) => React.ReactNode;
}) {
const hrefFor = useOpenHref();
const follow = (key: string) => (event: React.MouseEvent) => {
if (!isPlainClick(event)) return;
event.preventDefault();
onSelect(key);
};
return (
<ItemGroup className="gap-0.5 p-3">
{rows.map((row) => {
const key = getKey(row);
const active = key === activeKey;
return (
<div key={key} role="listitem">
<Item
size="sm"
render={
<a href={hrefFor(key)} aria-current={active && explicit ? "true" : undefined} onClick={follow(key)} />
}
className={cn(active && (explicit ? "bg-muted" : "lg:bg-muted"))}
>
{renderItem(row)}
</Item>
</div>
);
})}
</ItemGroup>
);
}
const resolveSelection = <T,>(
rows: T[],
selectedKey: string | null,
opened: T | undefined,
getKey: (row: T) => string,
) => {
const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey);
return { chosen, selected: selectedKey === null ? rows[0] : chosen };
};
function useSheet<T>(
chosen: T | undefined,
missingKey: string | undefined,
getKey: (row: T) => string,
renderDetail: (row: T) => React.ReactNode,
) {
const [content, setContent] = React.useState<{ row?: T; missingKey?: string }>({ row: chosen });
if (chosen !== undefined && chosen !== content.row) setContent({ row: chosen });
if (missingKey !== undefined && missingKey !== content.missingKey) setContent({ missingKey });
const { row } = content;
return {
open: chosen !== undefined || missingKey !== undefined,
title: row ? getKey(row) : content.missingKey ?? i18n.t("publicModelHub.masterDetail.details"),
body: row ? renderDetail(row) : content.missingKey && <NotListed name={content.missingKey} />,
};
}
export function HubMasterDetail<T>({
switcher,
toolbar,
rows,
loading,
selectedKey,
opened,
openPending = false,
getKey,
onSelect,
renderItem,
renderDetail,
empty,
footer,
}: {
switcher?: React.ReactNode;
toolbar: React.ReactNode;
rows: T[];
loading: boolean;
selectedKey: string | null;
opened?: T;
openPending?: boolean;
getKey: (row: T) => string;
onSelect: (key: string | null) => void;
renderItem: (row: T) => React.ReactNode;
renderDetail: (row: T) => React.ReactNode;
empty: React.ReactNode;
footer?: React.ReactNode;
}) {
const narrow = useIsNarrow();
const { chosen, selected } = resolveSelection(rows, selectedKey, opened, getKey);
const isEmpty = !loading && rows.length === 0;
const settled = !loading && !openPending && !isEmpty;
const missingKey = selectedKey !== null && chosen === undefined && settled ? selectedKey : undefined;
const sheet = useSheet(chosen, missingKey, getKey, renderDetail);
const listOnly = isEmpty && chosen === undefined && !openPending;
const detail = () => {
if (loading || (selected === undefined && openPending)) return <DetailSkeleton />;
if (selected === undefined) return selectedKey && <NotListed name={selectedKey} />;
return renderDetail(selected);
};
const list = isEmpty ? (
empty
) : (
<>
<Rows
rows={rows}
activeKey={selected && getKey(selected)}
explicit={chosen !== undefined}
getKey={getKey}
onSelect={onSelect}
renderItem={renderItem}
/>
{footer}
</>
);
return (
<div
className={cn(
"grid min-h-0 grid-cols-1 overflow-hidden rounded-xl border border-border lg:h-[calc(100dvh-18.875rem)] lg:min-h-[560px]",
!listOnly && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
)}
>
<div className={cn("flex min-h-0 flex-col", !listOnly && "lg:border-r lg:border-border")}>
<div className="flex flex-col gap-3 border-b border-border p-3">
{switcher}
<div className="flex flex-col gap-2">{toolbar}</div>
</div>
<ScrollArea className="min-h-0 flex-1">{loading ? <ListSkeleton /> : list}</ScrollArea>
</div>
{!listOnly && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
<DetailSheet open={narrow && sheet.open} title={sheet.title} onClose={() => onSelect(null)}>
{sheet.body}
</DetailSheet>
</div>
);
}
function DetailSheet({
open,
title,
onClose,
children,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
const body = React.useRef<HTMLDivElement>(null);
return (
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="bottom"
showCloseButton={false}
initialFocus={body}
className="max-h-[85dvh] gap-0 rounded-t-xl p-0 lg:hidden"
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<div ref={body} tabIndex={-1} className="min-h-0 overflow-y-auto outline-none">
<InSheet.Provider value>{children}</InSheet.Provider>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -1,18 +1,12 @@
"use client";
import { X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { SheetClose } from "@/components/ui/sheet";
import { cn } from "@/lib/cva.config";
import { SECTION_TITLE } from "./hubFormat";
import { CopyIconButton } from "./HubParts";
export const InSheet = React.createContext(false);
export function InspectorHeader({
media,
title,
@ -26,8 +20,6 @@ export function InspectorHeader({
status?: React.ReactNode;
description?: React.ReactNode;
}) {
const { t } = useTranslation();
const inSheet = React.useContext(InSheet);
return (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
@ -36,20 +28,6 @@ export function InspectorHeader({
<CopyIconButton value={title} label={copyLabel} className="size-7" />
<span className="flex-1" />
{status && <span className="hidden sm:flex">{status}</span>}
{inSheet && (
<SheetClose
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t("publicModelHub.inspectorHeader.close")}
className="text-muted-foreground"
/>
}
>
<X />
</SheetClose>
)}
</div>
{status && <span className="flex sm:hidden">{status}</span>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}

View file

@ -1,36 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import type { MCPServerData } from "@/components/PublicModelHubTableColumns";
import { mcpAuthLabel } from "./hubFormat";
import { CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
import { mcpEndpoint, mcpSnippet } from "./hubSnippets";
import { InspectorHeader } from "./InspectorHeader";
export function McpInspector({ server }: { server: MCPServerData }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader
title={server.name}
copyLabel={t("publicModelHub.mcpInspector.copyName")}
description={server.mcp_info?.description}
/>
<Facts
groups={[
[
{ label: t("publicModelHub.mcpInspector.facts.transport"), value: server.transport },
{ label: t("publicModelHub.mcpInspector.facts.signIn"), value: mcpAuthLabel(server.auth_type) },
{ label: t("publicModelHub.mcpInspector.facts.alias"), value: server.alias },
],
]}
/>
<EndpointField id={`mcp-endpoint-${server.server_id}`} value={mcpEndpoint(server)} />
<PaneSection title={t("publicModelHub.mcpInspector.connect")}>
<CodeBlock code={mcpSnippet(server)} />
</PaneSection>
</div>
);
}

View file

@ -1,24 +1,26 @@
"use client";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes } from "lucide-react";
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable";
import { ButtonGroup } from "@/components/ui/button-group";
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Toggle } from "@/components/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { displayMode } from "@/lib/decisionModels";
import i18n from "@/i18n/i18n";
import { FilterChip } from "./FilterChip";
import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat";
import { HubMasterDetail } from "./HubMasterDetail";
import { LoadFailed, NoResults } from "./HubEmpty";
import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts";
import { CatalogDetails } from "./CatalogDetails";
import { CatalogFilters } from "./CatalogFilters";
import { featureFacetLabel, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat";
import { LoadFailed, NoResults, NotListed } from "./HubEmpty";
import { ProviderAvatar, SearchInput } from "./HubParts";
import { type HubData, useOpenedModel } from "./useHubData";
import { useOpenHref } from "./useHubUrlState";
import { ModelInspector } from "./ModelInspector";
const SORTS = [
@ -34,42 +36,12 @@ const SORTS = [
type Models = HubData["models"];
function ModelsPager({ models }: { models: Models }) {
const { t } = useTranslation();
const { pagination, rowCount, rows, onPaginationChange } = models;
if (!rowCount) return null;
const start = pagination.pageIndex * pagination.pageSize + 1;
const end = start + rows.length - 1;
const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex });
return (
<div className="flex items-center justify-between gap-2 px-6 pb-3 text-xs text-muted-foreground tabular-nums">
<span>{t("publicModelHub.modelsPager.range", { start, end, total: rowCount })}</span>
<ButtonGroup>
<Button
variant="outline"
size="icon-sm"
aria-label={t("publicModelHub.modelsPager.previous")}
disabled={pagination.pageIndex === 0}
onClick={() => go(pagination.pageIndex - 1)}
>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={t("publicModelHub.modelsPager.next")}
disabled={start + pagination.pageSize > rowCount}
onClick={() => go(pagination.pageIndex + 1)}
>
<ChevronRight />
</Button>
</ButtonGroup>
</div>
);
}
function SortControl({ models }: { models: Models }) {
const { t } = useTranslation();
const options = SORTS.map((sort) => ({
...sort,
label: t(`publicModelHub.catalog.sort.${sort.value}`, { defaultValue: sort.label }),
}));
const field = models.sorting[0]?.id ?? "model_group";
const desc = models.sorting[0]?.desc ?? false;
const pick = (value: string | null) => {
@ -78,13 +50,13 @@ function SortControl({ models }: { models: Models }) {
};
return (
<ButtonGroup>
<Select items={SORTS} value={field} onValueChange={pick}>
<Select items={options} value={field} onValueChange={pick}>
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label={t("publicModelHub.sortControl.sortBy")}>
<span className="hidden text-muted-foreground sm:inline">{t("publicModelHub.sortControl.sort")}</span>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="w-auto min-w-(--anchor-width)">
{SORTS.map((sort) => (
{options.map((sort) => (
<SelectItem key={sort.value} value={sort.value}>
{sort.label}
</SelectItem>
@ -112,100 +84,106 @@ function SortControl({ models }: { models: Models }) {
);
}
function ModelsToolbar({ hub }: { hub: HubData }) {
const { t } = useTranslation();
const { models, facets } = hub;
interface FilterOption {
value: string;
label: string;
provider?: string;
}
function CatalogFilter({
label,
options,
values,
onChange,
}: {
label: string;
options: FilterOption[];
values: string[];
onChange: (values: string[]) => void;
}) {
return (
<>
<div className="flex items-center gap-2">
<SearchInput
value={models.searchValue}
onChange={models.onSearchChange}
placeholder={t("publicModelHub.modelsToolbar.search")}
/>
<SortControl models={models} />
<fieldset data-slot="catalog-filter-section" className="min-w-0">
<legend className="mb-3 text-sm font-medium">{label}</legend>
<div className="flex flex-col gap-3">
{options.map((option) => (
<label key={option.value} className="flex cursor-pointer items-center gap-2.5 text-sm">
<Checkbox
checked={values.includes(option.value)}
onCheckedChange={(checked) =>
onChange(checked ? [...values, option.value] : values.filter((value) => value !== option.value))
}
/>
{option.provider && <ProviderAvatar provider={option.provider} />}
<span className="min-w-0 break-words">{option.label}</span>
</label>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip
label={t("publicModelHub.modelsToolbar.provider")}
noun={t("publicModelHub.modelsToolbar.providerNoun")}
options={facets.providers.map((p) => ({ value: p, label: providerName(p), provider: p }))}
value={models.providerValues}
onChange={models.onProvidersChange}
/>
<FilterChip
label={t("publicModelHub.modelsToolbar.mode")}
noun={t("publicModelHub.modelsToolbar.modeNoun")}
options={[...new Set(facets.modes.map((m) => displayMode(m) ?? m))].map((m) => ({
value: m,
label: modeLabel(m),
}))}
value={models.modeValues}
onChange={models.onModesChange}
/>
<FilterChip
label={t("publicModelHub.modelsToolbar.features")}
noun={t("publicModelHub.modelsToolbar.featureNoun")}
options={facets.features.map((f) => ({ value: f, label: featureFacetLabel(f) }))}
value={models.featureValues}
onChange={models.onFeaturesChange}
/>
</div>
</>
</fieldset>
);
}
const price = (cost: number | null | undefined, direction: "input" | "output") => {
if (cost === 0) return i18n.t("publicModelHub.modelPrices.freeDirection", { direction });
return cost
? i18n.t("publicModelHub.modelPrices.perMillion", {
price: formatPerMillion(cost),
direction:
direction === "input" ? i18n.t("publicModelHub.modelPrices.in") : i18n.t("publicModelHub.modelPrices.out"),
})
: null;
};
const prices = (model: ModelGroupInfo) =>
model.input_cost_per_token === 0 && model.output_cost_per_token === 0
? [i18n.t("publicModelHub.modelPrices.free"), null]
: [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")];
function ModelRow({ model }: { model: ModelGroupInfo }) {
function ModelFilters({ hub }: { hub: HubData }) {
const { t } = useTranslation();
const [input, output] = prices(model);
const priced = Boolean(input || output);
const { models, facets } = hub;
return (
<>
<ItemMedia variant="icon" className="size-5">
<ProviderAvatar provider={model.providers?.[0] ?? ""} className="size-5" />
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<RowTitle>{model.model_group}</RowTitle>
<ItemDescription>
<Joined
parts={[
firstPlus((model.providers ?? []).map(providerName)),
model.mode ? modeLabel(model.mode) : null,
model.max_input_tokens
? t("publicModelHub.modelRow.context", { tokens: formatTokens(model.max_input_tokens) })
: null,
]}
/>
</ItemDescription>
{priced && (
<ItemDescription className="tabular-nums sm:hidden">
<Joined parts={[input, output]} />
</ItemDescription>
)}
</ItemContent>
{priced && (
<ItemActions className="hidden flex-col items-end gap-0.5 text-xs whitespace-nowrap tabular-nums sm:flex">
{input && <span>{input}</span>}
{output && <span className="text-muted-foreground">{output}</span>}
</ItemActions>
)}
</>
<CatalogFilters count={models.columnFilters.length} onClear={() => models.onColumnFiltersChange([])}>
<CatalogFilter
label={t("publicModelHub.catalog.provider", { defaultValue: "Provider" })}
options={facets.providers.map((provider) => ({ value: provider, label: providerName(provider), provider }))}
values={models.providerValues}
onChange={models.onProvidersChange}
/>
<CatalogFilter
label={t("publicModelHub.catalog.capabilities", { defaultValue: "Capabilities" })}
options={facets.features.map((feature) => ({ value: feature, label: featureFacetLabel(feature) }))}
values={models.featureValues}
onChange={models.onFeaturesChange}
/>
<CatalogFilter
label={t("publicModelHub.catalog.mode", { defaultValue: "Mode" })}
options={[...new Set(facets.modes.map((mode) => displayMode(mode) ?? mode))].map((mode) => ({
value: mode,
label: modeLabel(mode),
}))}
values={models.modeValues}
onChange={models.onModesChange}
/>
</CatalogFilters>
);
}
function ModelLink({
model,
selected,
onSelect,
}: {
model: ModelGroupInfo;
selected: string | null;
onSelect: (key: string | null) => void;
}) {
const hrefFor = useOpenHref();
const follow = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.defaultPrevented) return;
if (event.metaKey || event.ctrlKey) return;
if (event.shiftKey || event.altKey) return;
event.preventDefault();
onSelect(model.model_group);
};
return (
<a
href={hrefFor(model.model_group)}
aria-current={selected === model.model_group ? "true" : undefined}
className="flex min-w-0 items-center gap-3 rounded-sm py-2 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={follow}
>
<ProviderAvatar provider={model.providers?.[0] ?? ""} className="size-6 shrink-0 sm:size-8" />
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium whitespace-normal wrap-anywhere">{model.model_group}</span>
<span className="text-xs whitespace-normal text-muted-foreground">
{(model.providers ?? []).map(providerName).join(", ")}
</span>
</span>
</a>
);
}
@ -221,35 +199,122 @@ export function ModelsPane({
const { t } = useTranslation();
const { models } = hub;
const opened = useOpenedModel(selected, models.rows, !models.isLoading);
const detailsLoading = opened.model === undefined && (models.isLoading || opened.pending);
const missingName = opened.model === undefined && !detailsLoading ? selected : null;
const countOptions = {
count: models.rowCount,
defaultValue_one: "{{count}} model",
defaultValue_other: "{{count}} models",
defaultValue: "{{count}} models",
};
const columns: ColumnDef<ModelGroupInfo>[] = [
{
accessorKey: "model_group",
meta: { skeleton: "twoLine" },
sortDescFirst: false,
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("publicModelHub.catalog.model", { defaultValue: "Model" })} />
),
cell: ({ row }) => <ModelLink model={row.original} selected={selected} onSelect={onSelect} />,
},
{
accessorKey: "max_input_tokens",
meta: { numeric: true, className: "hidden sm:table-cell" },
size: 140,
sortDescFirst: true,
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("publicModelHub.catalog.inputLimit", { defaultValue: "Input limit" })}
/>
),
cell: ({ row }) => (row.original.max_input_tokens === 0 ? "0" : formatTokens(row.original.max_input_tokens)),
},
{
accessorKey: "input_cost_per_token",
meta: { numeric: true },
size: 130,
sortDescFirst: false,
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("publicModelHub.catalog.inputPrice", { defaultValue: "Input /1M" })}
/>
),
cell: ({ row }) => formatPerMillion(row.original.input_cost_per_token),
},
{
accessorKey: "output_cost_per_token",
meta: { numeric: true },
size: 130,
sortDescFirst: false,
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("publicModelHub.catalog.outputPrice", { defaultValue: "Output /1M" })}
/>
),
cell: ({ row }) => formatPerMillion(row.original.output_cost_per_token),
},
];
const clear = () => {
models.onSearchChange("");
models.onColumnFiltersChange([]);
};
return (
<HubMasterDetail
rows={models.rows}
loading={models.isLoading}
selectedKey={selected}
opened={opened.model}
openPending={opened.pending}
getKey={(model) => model.model_group}
onSelect={onSelect}
empty={
models.error ? (
<LoadFailed noun={t("publicModelHub.modelsPane.noun")} onRetry={models.refetch} />
) : (
<NoResults
icon={<Boxes />}
noun={t("publicModelHub.modelsPane.noun")}
query={models.searchValue}
onClear={models.hasActiveQuery ? clear : undefined}
<div data-slot="catalog-layout" className="grid min-w-0 gap-8 lg:grid-cols-[220px_minmax(0,1fr)]">
<ModelFilters hub={hub} />
<div data-slot="catalog-results" className="flex min-w-0 flex-col gap-4">
<div data-slot="catalog-toolbar" className="flex flex-wrap items-center gap-3">
<SearchInput
value={models.searchValue}
onChange={models.onSearchChange}
placeholder={t("publicModelHub.catalog.searchModels", { defaultValue: "Search models" })}
/>
)
}
toolbar={<ModelsToolbar hub={hub} />}
renderItem={(model) => <ModelRow model={model} />}
renderDetail={(model) => <ModelInspector model={model} />}
footer={<ModelsPager models={models} />}
/>
<SortControl models={models} />
</div>
<div data-slot="catalog-count" className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
{models.isFetching || models.isLoading
? t("publicModelHub.catalog.loadingModels", { defaultValue: "Loading models…" })
: !models.error && t("publicModelHub.catalog.modelCount", countOptions)}
</div>
{models.error && models.rows.length > 0 && (
<LoadFailed noun={t("publicModelHub.modelsPane.noun")} onRetry={models.refetch} />
)}
<div data-slot="catalog-table" aria-busy={models.isFetching || models.isLoading}>
<DataTable
data={models.rows}
columns={columns}
getRowId={(model) => model.model_group}
sortingMode="server"
sorting={models.sorting}
onSortingChange={models.onSortingChange}
paginationMode="server"
pagination={models.pagination}
onPaginationChange={models.onPaginationChange}
rowCount={models.rowCount}
filterMode="server"
columnFilters={models.columnFilters}
onColumnFiltersChange={models.onColumnFiltersChange}
isLoading={models.isLoading}
isError={Boolean(models.error)}
errorMessage={t("publicModelHub.loadFailed.title", { noun: t("publicModelHub.modelsPane.noun") })}
onRetry={models.refetch}
noDataMessage={
<NoResults
icon={<Boxes />}
noun={t("publicModelHub.modelsPane.noun")}
query={models.searchValue}
onClear={models.hasActiveQuery ? clear : undefined}
/>
}
/>
</div>
</div>
<CatalogDetails name={selected} loading={detailsLoading} onClose={() => onSelect(null)}>
{opened.model && <ModelInspector model={opened.model} />}
{missingName !== null && <NotListed name={missingName} />}
</CatalogDetails>
</div>
);
}

View file

@ -1,13 +1,19 @@
import { fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import type { Plugin } from "@/components/claude_code_plugins/types";
import type { apiClient } from "@/components/networking";
import { chooseSelectOption, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import PublicAiHub from "./PublicAiHub";
const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
apiGetMock: vi.fn(),
realGet: { current: (..._args: unknown[]): Promise<unknown> => Promise.reject(new Error("unset")) },
realGet: {
current: (..._args: Parameters<typeof apiClient.get>): Promise<unknown> => Promise.reject(new Error("unset")),
},
navigation: { params: new URLSearchParams() },
uiSettings: { values: {} as Record<string, unknown> },
replaceMock: vi.fn(),
@ -25,7 +31,13 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
}));
vi.mock("@/components/navbar", () => ({ default: () => <nav aria-label="Top bar" /> }));
vi.mock("next/font/local", () => ({ default: () => ({ variable: "catalog-display-font" }) }));
vi.mock("@/components/navbar", () => ({
default: ({ publicNavigation }: { publicNavigation?: ReactNode }) => (
<nav aria-label="Top bar">{publicNavigation}</nav>
),
}));
vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn() }));
@ -46,6 +58,7 @@ vi.mock("@/components/networking", async (importOriginal) => {
});
const MODEL_HUB_PATH = "/public/v1/model_hub";
const SKILL_HUB_PATH = "/public/skill_hub";
const MODELS = [
{
@ -53,6 +66,7 @@ const MODELS = [
providers: ["anthropic"],
mode: "chat",
input_cost_per_token: 0.000003,
max_input_tokens: 200000,
description: "Best for agentic coding",
},
{
@ -61,27 +75,61 @@ const MODELS = [
mode: "chat",
input_cost_per_token: 0,
output_cost_per_token: 0,
max_input_tokens: 0,
description: "General purpose",
},
];
const AGENTS = [
const SKILLS: Plugin[] = [
{
id: "skill-research",
name: "research-assistant",
description: "Answers research questions",
version: "1.4.0",
url: "https://gateway.example.com/a2a/research",
protocolVersion: "0.3.0",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
skills: [],
source: { source: "github", repo: "example/research-tools" },
namespace: "knowledge",
domain: "Research",
category: "Analysis",
keywords: ["evidence"],
author: { name: "Research maintainers" },
enabled: true,
created_at: null,
updated_at: null,
},
{
id: "skill-review",
name: "review-changes",
description: "Checks pull requests",
version: "2.0.0",
source: { source: "github", repo: "example/review-tools" },
namespace: "development",
domain: "Engineering",
category: "Code review",
keywords: ["quality"],
enabled: true,
created_at: null,
updated_at: null,
},
{
id: "skill-release",
name: "release-notes",
description: "Summarizes shipped changes",
version: "1.0.0",
source: { source: "github", repo: "example/release-tools" },
namespace: "delivery",
domain: "Engineering",
category: "Documentation",
keywords: ["changelog"],
enabled: true,
created_at: null,
updated_at: null,
},
];
const HUBS: Record<string, unknown> = {
"/public/agent_hub": AGENTS,
"/public/mcp_hub": [],
"/public/skill_hub": { plugins: [] },
const FACETS: Record<string, string[]> = {
[`${MODEL_HUB_PATH}/providers`]: ["anthropic", "openai"],
[`${MODEL_HUB_PATH}/modes`]: ["chat"],
[`${MODEL_HUB_PATH}/features`]: ["vision", "function_calling"],
};
const page = (data: unknown[]) => ({
@ -92,11 +140,12 @@ const page = (data: unknown[]) => ({
const OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" };
type Query = { query?: Record<string, unknown> };
type Query = NonNullable<Parameters<typeof apiClient.get>[1]>;
const respond = (path: string, options?: Query): Promise<unknown> => {
if (path in HUBS) return Promise.resolve(HUBS[path]);
if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } });
if (path === SKILL_HUB_PATH) return Promise.resolve({ plugins: SKILLS });
if (path in FACETS) return Promise.resolve(page(FACETS[path]));
if (path !== MODEL_HUB_PATH) return Promise.reject(new Error(`Unexpected API read: ${path}`));
const q = String(options?.query?.q ?? "");
if (q === "o1-pro") return Promise.resolve(page([OFF_PAGE_MODEL]));
return Promise.resolve(page(MODELS.filter((model) => model.model_group.includes(q))));
@ -109,24 +158,12 @@ const failModelsWith = (next: (options?: Query) => Promise<unknown>) =>
path === MODEL_HUB_PATH ? next(options) : respond(path, options),
);
const narrowScreen = (narrow: boolean) =>
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: narrow,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}),
});
const tokenExpiringIn = (seconds: number) => {
const encode = (value: object) => btoa(JSON.stringify(value)).replace(/=+$/, "");
return `${encode({ alg: "HS256" })}.${encode({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`;
};
beforeEach(() => {
narrowScreen(false);
vi.clearAllMocks();
testQueryClient.clear();
uiSettings.values = {};
@ -141,14 +178,26 @@ beforeEach(() => {
});
describe("PublicAiHub", () => {
it("counts every hub and shows the first model's details", async () => {
it("shows only Models and Skills without selecting an item or reading retired hubs", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /^Models\s*2$/ })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /^Agents\s*1$/ })).toBeInTheDocument();
expect(screen.getByText("Best for agentic coding")).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
expect(screen.getByText("2 models")).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(2);
expect(screen.getByRole("tab", { name: "Model Hub" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Skills Hub" })).toHaveAttribute("aria-selected", "false");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Base URL" })).toHaveValue("https://gateway.example.com");
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(screen.getByText("3 skills")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(apiGetMock.mock.calls.map(([path]) => path)).not.toEqual(
expect.arrayContaining([expect.stringMatching(/^\/public\/(?:agent|mcp)_hub$/)]),
);
});
it("opens a model from the list and records it in the URL", async () => {
@ -158,21 +207,25 @@ describe("PublicAiHub", () => {
await user.click(await screen.findByRole("link", { name: /gpt-5/ }));
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
const dialog = await screen.findByRole("dialog", { name: "Details" });
expect(within(dialog).getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
expect(within(dialog).getByText("General purpose")).toBeInTheDocument();
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ queryString: "?open=gpt-5" })),
);
});
it("explains an empty search and clears it", async () => {
it("explains an empty model search and clears the server query", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents" } });
renderWithProviders(<PublicAiHub accessToken={null} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
fireEvent.change(await screen.findByRole("textbox", { name: "Search models" }), { target: { value: "billing" } });
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
expect(await screen.findByText('No models match "billing"')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search models" })).toHaveValue("");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("lists the admin's links and the gateway version", async () => {
@ -188,14 +241,14 @@ describe("PublicAiHub", () => {
expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument();
});
it("keeps agents reachable when only the models call fails", async () => {
it("keeps skills reachable when only the models call fails", async () => {
const user = userEvent.setup();
failModelsWith(() => Promise.reject(new Error("No models configured")));
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("Couldn't load models")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search models" })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /^Agents\s*1$/ }));
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
});
@ -204,24 +257,31 @@ describe("PublicAiHub", () => {
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Search models" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows a load error when a hub answers with an HTTP error status", async () => {
it("shows an HTTP failure for Skills and retries without losing access to Models", async () => {
const user = userEvent.setup();
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ detail: "Database error" }), { status: 500 }));
apiGetMock.mockImplementation((path: string, options?: Query) =>
path === "/public/agent_hub" ? realGet.current(path, options) : respond(path, options),
path === SKILL_HUB_PATH ? realGet.current(path, options) : respond(path, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />);
await user.click(await screen.findByRole("tab", { name: /^Agents/ }));
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
expect(await screen.findByText("Couldn't load agents")).toBeInTheDocument();
expect(screen.queryByText("No agents published yet")).not.toBeInTheDocument();
expect(await screen.findByText("Couldn't load skills")).toBeInTheDocument();
expect(screen.queryByText("No skills published yet")).not.toBeInTheDocument();
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ plugins: SKILLS }), { status: 200 }));
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(screen.queryByText("Couldn't load skills")).not.toBeInTheDocument();
});
it("shows loading rather than an empty hub while Try again reloads models", async () => {
it("shows loading rather than an empty hub while Retry reloads models", async () => {
const user = userEvent.setup();
let finishRetry: (value: unknown) => void = () => {};
failModelsWith(() => Promise.reject(new Error("Gateway timeout")));
@ -229,84 +289,122 @@ describe("PublicAiHub", () => {
await screen.findByText("Couldn't load models");
failModelsWith(() => new Promise((resolve) => (finishRetry = resolve)));
await user.click(screen.getByRole("button", { name: "Try again" }));
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(screen.getByText("Loading models…")).toBeInTheDocument();
expect(screen.queryByText("No models published yet")).not.toBeInTheDocument();
expect(screen.queryByText("Couldn't load models")).not.toBeInTheDocument();
finishRetry(page(MODELS));
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("keeps the open model and its snippet tab while a search hides its row", async () => {
it("keeps model details and the selected snippet when an earlier search finishes", async () => {
const user = userEvent.setup();
let finishSearch: (value: unknown) => void = () => {};
failModelsWith((options) =>
options?.query?.q === "gpt-5" ? new Promise(() => {}) : respond(MODEL_HUB_PATH, options),
options?.query?.q === "claude"
? new Promise((resolve) => (finishSearch = resolve))
: respond(MODEL_HUB_PATH, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
renderWithProviders(<PublicAiHub accessToken={null} />);
await screen.findByRole("link", { name: /gpt-5/ });
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "claude" } });
fireEvent.click(screen.getByRole("link", { name: /gpt-5/ }));
await screen.findByRole("heading", { level: 2, name: "gpt-5" });
await user.click(screen.getByRole("tab", { name: "cURL" }));
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "claude" } });
await waitFor(() =>
expect(apiGetMock).toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: expect.objectContaining({ q: "claude" }) }),
),
);
finishSearch(page([MODELS[0]]));
await waitFor(() => expect(screen.queryByRole("link", { name: /gpt-5/ })).not.toBeInTheDocument());
expect(await screen.findByText("1 model")).toBeInTheDocument();
expect(screen.getAllByText("gpt-5")).toHaveLength(1);
expect(screen.getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "cURL" })).toHaveAttribute("aria-selected", "true");
expect(within(screen.getByRole("dialog")).getByRole("tabpanel", { name: "cURL" })).toHaveTextContent(
'"model": "gpt-5"',
);
expect(apiGetMock).not.toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: expect.objectContaining({ q: "gpt-5" }) }),
);
});
it("keeps the open item's details when a search matches nothing", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, {
searchParams: { tab: "agents", open: "research-assistant" },
});
it("looks up shared model details even when the catalog list is empty", async () => {
failModelsWith((options) => Promise.resolve(page(options?.query?.q === "gpt-5" ? [MODELS[1]] : [])));
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "research-assistant" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
expect(screen.queryByText("“gpt-5” isn't listed here")).not.toBeInTheDocument();
});
it("marks a free model as free in its row", async () => {
it("distinguishes zero and missing prices in the model table", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />);
const row = await screen.findByRole("link", { name: /gpt-5/ });
const freeRow = await screen.findByRole("row", { name: /gpt-5/ });
const pricedRow = screen.getByRole("row", { name: /claude-sonnet-4-5/ });
expect(within(row).getAllByText("Free").length).toBeGreaterThan(0);
expect(within(freeRow).getAllByRole("cell", { name: "Free" })).toHaveLength(2);
expect(within(freeRow).getByRole("cell", { name: "0" })).toBeInTheDocument();
expect(within(pricedRow).getByRole("cell", { name: "$3.00" })).toBeInTheDocument();
expect(within(pricedRow).getByRole("cell", { name: "-" })).toBeInTheDocument();
expect(within(pricedRow).queryByRole("cell", { name: "Free" })).not.toBeInTheDocument();
});
it("treats an empty open param as nothing opened", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "" } });
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("tells phone visitors when a shared link names something no longer listed", async () => {
narrowScreen(true);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
it.each(["models", "skills"])("reports a missing shared item in the %s dialog", async (tab) => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab, open: "retired-item" } });
const sheet = await screen.findByRole("dialog");
const dialog = await screen.findByRole("dialog", { name: "Details" });
expect(within(sheet).getByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
expect(await within(dialog).findByText("“retired-item” isn't listed here")).toBeInTheDocument();
expect(within(dialog).queryByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).not.toBeInTheDocument();
});
it("keeps the page's other query params in each row's link", async () => {
navigation.params = new URLSearchParams("tab=agents&key=abc");
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents", key: "abc" } });
it.each([
{ tab: "models", name: "gpt-5" },
{ tab: "skills", name: "research-assistant" },
])("preserves query params and native modified clicks on $tab links", async ({ tab, name }) => {
const onUrlUpdate = vi.fn();
const searchParams = { tab, key: "abc", ref: "shared catalog" };
navigation.params = new URLSearchParams(searchParams);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams, onUrlUpdate });
expect(await screen.findByRole("link", { name: /research-assistant/ })).toHaveAttribute(
"href",
"?tab=agents&key=abc&open=research-assistant",
);
const link = await screen.findByRole("link", { name: new RegExp(name) });
expect(link).toHaveAttribute("href", `?tab=${tab}&key=abc&ref=shared+catalog&open=${name}`);
for (const modifier of ["ctrlKey", "metaKey", "shiftKey", "altKey"]) {
expect(fireEvent.click(link, { [modifier]: true })).toBe(true);
}
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(onUrlUpdate).not.toHaveBeenCalled();
});
it("clears the open item when switching tabs", async () => {
it("clears the detail URL on close and opens Skills without the old model selection", async () => {
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" }, onUrlUpdate });
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /^Agents/ }));
await user.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" })));
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=agents" })),
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=skills" })),
);
});
@ -315,17 +413,36 @@ describe("PublicAiHub", () => {
expect(await screen.findByRole("heading", { level: 2, name: "o1-pro" })).toBeInTheDocument();
expect(screen.getByText("Deep reasoning")).toBeInTheDocument();
expect(apiGetMock).toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: { q: "o1-pro", page_size: 50 } }),
);
});
it("says when a shared link names something that is no longer listed", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
it.each(["agents", "mcp"])("immediately discards incompatible selection from a retired %s tab", async (tab) => {
const onUrlUpdate = vi.fn();
renderWithProviders(<PublicAiHub accessToken={null} />, {
searchParams: { tab, open: "gpt-5", ref: "shared" },
onUrlUpdate,
resetUrlUpdateQueueOnMount: false,
});
expect(await screen.findByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Model Hub" })).toHaveAttribute("aria-selected", "true");
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?ref=shared" })),
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(apiGetMock).not.toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: expect.objectContaining({ q: "gpt-5" }) }),
);
});
it("sends visitors without a session to login when the admin requires it", async () => {
it.each(["missing", "expired"])("redirects a visitor with a %s session when sign-in is required", async (session) => {
uiSettings.values = { require_auth_for_public_ai_hub: true };
if (session === "expired") document.cookie = `token=${tokenExpiringIn(-3600)}; path=/`;
renderWithProviders(<PublicAiHub accessToken={null} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("login")));
@ -336,7 +453,136 @@ describe("PublicAiHub", () => {
document.cookie = `token=${tokenExpiringIn(3600)}; path=/`;
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("wires model search, provider, capability, mode, and sorting controls into the list API", async () => {
const user = userEvent.setup();
failModelsWith((options) =>
options?.query?.["filter[providers][in]"] ? Promise.resolve(page([MODELS[1]])) : respond(MODEL_HUB_PATH, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />);
await screen.findByRole("link", { name: /claude-sonnet-4-5/ });
await user.click(screen.getByRole("checkbox", { name: /OpenAI/ }));
await user.click(screen.getByRole("checkbox", { name: "Vision" }));
await user.click(screen.getByRole("checkbox", { name: "Chat" }));
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "gpt" } });
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Sort by" }), "Input price");
await waitFor(() =>
expect(apiGetMock).toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({
query: {
page: 1,
page_size: 50,
q: "gpt",
sort: "input_cost_per_token",
"filter[providers][in]": "openai",
"filter[features][in]": "vision",
"filter[mode][in]": "chat",
},
}),
),
);
expect(await screen.findByText("1 model")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /claude-sonnet-4-5/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Sort descending" }));
await waitFor(() =>
expect(apiGetMock).toHaveBeenCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: expect.objectContaining({ sort: "-input_cost_per_token", q: "gpt" }) }),
),
);
await user.click(screen.getByRole("button", { name: "Clear filters" }));
await waitFor(() =>
expect(apiGetMock).toHaveBeenLastCalledWith(
MODEL_HUB_PATH,
expect.objectContaining({ query: { page: 1, page_size: 50, q: "gpt", sort: "-input_cost_per_token" } }),
),
);
expect(screen.getByRole("checkbox", { name: /OpenAI/ })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Vision" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Chat" })).not.toBeChecked();
});
it("combines skill domain and search filters and clears an empty result", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" } });
await screen.findByRole("link", { name: /research-assistant/ });
await user.click(screen.getByRole("checkbox", { name: "Engineering" }));
expect(screen.queryByRole("link", { name: /research-assistant/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /review-changes/ })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
expect(screen.getByText("2 skills")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "changelog" } });
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "evidence" } });
expect(screen.getByText('No skills match "evidence"')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^All domains/ }));
expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(screen.getByText("3 skills")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search skills" })).toHaveValue("");
expect(screen.getByRole("checkbox", { name: "Engineering" })).not.toBeChecked();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("finds skills by name, description, namespace, domain, and keywords", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" } });
await screen.findByRole("link", { name: /research-assistant/ });
const search = screen.getByRole("textbox", { name: "Search skills" });
for (const value of ["research-assistant", "Answers research questions", "knowledge", "Research", "evidence"]) {
fireEvent.change(search, { target: { value } });
expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument();
}
});
it("opens the chosen skill with its source and install commands and updates the URL", async () => {
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" }, onUrlUpdate });
await user.click(await screen.findByRole("link", { name: /research-assistant/ }));
const dialog = await screen.findByRole("dialog", { name: "Details" });
expect(within(dialog).getByRole("heading", { name: "research-assistant", level: 2 })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "github.com/example/research-tools" })).toHaveAttribute(
"href",
"https://github.com/example/research-tools",
);
expect(within(dialog).getByText("knowledge")).toBeInTheDocument();
expect(within(dialog).getByText("Research")).toBeInTheDocument();
expect(
within(dialog).getByText("/plugin marketplace add https://gateway.example.com/claude-code/marketplace.json"),
).toBeInTheDocument();
expect(within(dialog).getByText("/plugin install research-assistant@litellm")).toBeInTheDocument();
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ queryString: "?tab=skills&open=research-assistant" }),
),
);
});
it("selects the skill named by a shared link instead of the first skill", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, {
searchParams: { tab: "skills", open: "review-changes" },
});
expect(await screen.findByRole("heading", { level: 2, name: "review-changes" })).toBeInTheDocument();
expect(screen.getByText("/plugin install review-changes@litellm")).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "release-notes" })).not.toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import localFont from "next/font/local";
import { useTranslation } from "react-i18next";
import Navbar from "@/components/navbar";
@ -11,93 +11,69 @@ import { ThemeProvider } from "@/contexts/ThemeContext";
import { HubHeader, type HubStatus } from "./HubHeader";
import { HubNotEnabled } from "./HubEmpty";
import { ModelsPane } from "./ModelsPane";
import { AgentsPane, McpPane, SkillsPane } from "./ResourcePanes";
import { SkillsPane } from "./ResourcePanes";
import { type HubData, useHubData } from "./useHubData";
import { type HubTab, useHubUrlState } from "./useHubUrlState";
import { useRequirePublicHubAuth } from "./useRequirePublicHubAuth";
import styles from "./catalog.module.css";
function TabLabel({ children, count }: { children: ReactNode; count: number | undefined }) {
return (
<>
{children}
<span className="text-xs text-muted-foreground tabular-nums">{count ?? "-"}</span>
</>
);
}
const displayFont = localFont({
src: "./fonts/barlow-condensed-latin-600.ttf",
weight: "600",
variable: "--font-catalog-display",
});
const hubStatus = (hub: HubData): HubStatus => {
const failures = [hub.models.error, hub.agents.error, hub.mcp.error, hub.skills.error].filter(Boolean);
if (failures.length === 4) return "unavailable";
if (hub.models.isLoading) return "loading";
return failures.length > 0 ? "degraded" : "operational";
if (hub.models.error && hub.skills.isError) return "unavailable";
if (hub.models.isLoading || hub.skills.isPending) return "loading";
return hub.models.error || hub.skills.isError ? "degraded" : "operational";
};
function HubTabs({ hub }: { hub: HubData }) {
function PublicAiHubContent({ accessToken }: { accessToken: string | null }) {
const { t } = useTranslation();
const { tab, setTab, open, setOpen } = useHubUrlState();
const paneProps = { selected: open, onSelect: setOpen };
return (
<Tabs value={tab} onValueChange={(next) => setTab(next as HubTab)} className="gap-4">
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="models" className="px-1.5 sm:px-2">
<TabLabel count={hub.modelTotal}>{t("publicModelHub.tabs.models")}</TabLabel>
</TabsTrigger>
<TabsTrigger value="agents" className="px-1.5 sm:px-2">
<TabLabel count={hub.agents.data?.length}>{t("publicModelHub.tabs.agents")}</TabLabel>
</TabsTrigger>
<TabsTrigger value="mcp" className="px-1.5 sm:px-2">
<TabLabel count={hub.mcp.data?.length}>
<span className="sm:hidden">{t("publicModelHub.tabs.mcpShort")}</span>
<span className="hidden sm:inline">{t("publicModelHub.tabs.mcpServers")}</span>
</TabLabel>
</TabsTrigger>
<TabsTrigger value="skills" className="px-1.5 sm:px-2">
<TabLabel count={hub.skills.data?.length}>{t("publicModelHub.tabs.skills")}</TabLabel>
</TabsTrigger>
</TabsList>
<TabsContent value="models">
<ModelsPane hub={hub} {...paneProps} />
</TabsContent>
<TabsContent value="agents">
<AgentsPane
rows={hub.agents.data}
loading={hub.agents.isPending}
error={hub.agents.isError}
onRetry={() => void hub.agents.refetch()}
{...paneProps}
/>
</TabsContent>
<TabsContent value="mcp">
<McpPane
rows={hub.mcp.data}
loading={hub.mcp.isPending}
error={hub.mcp.isError}
onRetry={() => void hub.mcp.refetch()}
{...paneProps}
/>
</TabsContent>
<TabsContent value="skills">
<SkillsPane
rows={hub.skills.data}
loading={hub.skills.isPending}
error={hub.skills.isError}
onRetry={() => void hub.skills.refetch()}
{...paneProps}
/>
</TabsContent>
</Tabs>
);
}
function PublicAiHubContent() {
useRequirePublicHubAuth();
const hub = useHubData();
const status = hubStatus(hub);
const { tab, setTab, open, setOpen } = useHubUrlState();
const paneProps = { selected: open, onSelect: setOpen };
return (
<div className="flex w-full flex-col gap-6 p-4 sm:p-6">
<HubHeader info={hub.info.data} status={status} />
{status === "unavailable" ? <HubNotEnabled /> : <HubTabs hub={hub} />}
</div>
<Tabs
value={tab}
onValueChange={(next) => setTab(next as HubTab)}
className={`${styles.catalog} ${displayFont.variable}`}
>
<Navbar
accessToken={accessToken}
isPublicPage
publicNavigation={
<TabsList variant="line" className={styles.navigation} aria-label={t("publicModelHub.header.title")}>
<TabsTrigger value="models">{t("publicModelHub.catalog.modelHub")}</TabsTrigger>
<TabsTrigger value="skills">{t("publicModelHub.catalog.skillsHub")}</TabsTrigger>
</TabsList>
}
/>
<main className={styles.main}>
<HubHeader info={hub.info.data} status={status} tab={tab} />
{status === "unavailable" ? (
<HubNotEnabled />
) : (
<>
<TabsContent value="models">
<ModelsPane hub={hub} {...paneProps} />
</TabsContent>
<TabsContent value="skills">
<SkillsPane
rows={hub.skills.data}
loading={hub.skills.isPending}
error={hub.skills.isError}
onRetry={() => void hub.skills.refetch()}
{...paneProps}
/>
</TabsContent>
</>
)}
</main>
</Tabs>
);
}
@ -105,10 +81,7 @@ export default function PublicAiHub({ accessToken }: { accessToken: string | nul
return (
<ThemeProvider accessToken={accessToken}>
<TooltipProvider>
<div className="min-h-screen bg-background">
<Navbar accessToken={accessToken} isPublicPage />
<PublicAiHubContent />
</div>
<PublicAiHubContent accessToken={accessToken} />
</TooltipProvider>
</ThemeProvider>
);

View file

@ -1,197 +1,232 @@
"use client";
import { Bot, Puzzle, Server } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Puzzle } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { getSourceDisplayText } from "@/components/claude_code_plugins/helpers";
import type { Plugin } from "@/components/claude_code_plugins/types";
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { matchesSearchTerm } from "@/utils/searchUtils";
import { AgentInspector } from "./AgentInspector";
import { FilterChip } from "./FilterChip";
import { mcpAuthLabel } from "./hubFormat";
import { HubMasterDetail } from "./HubMasterDetail";
import { LoadFailed, NoResults } from "./HubEmpty";
import { RowTitle, SearchInput } from "./HubParts";
import { McpInspector } from "./McpInspector";
import { CatalogDetails } from "./CatalogDetails";
import { CatalogFilters } from "./CatalogFilters";
import { LoadFailed, NoResults, NotListed } from "./HubEmpty";
import { SearchInput } from "./HubParts";
import { SkillInspector } from "./SkillInspector";
import { useOpenHref } from "./useHubUrlState";
interface ResourcePaneProps<T> {
rows: T[];
interface SkillsPaneProps {
rows: Plugin[] | undefined;
loading: boolean;
error: boolean;
onRetry: () => void;
noun: string;
icon: React.ReactNode;
search: (row: T) => (string | null | undefined)[];
filter: { label: string; noun: string; values: (row: T) => string[] };
selected: string | null;
onSelect: (key: string | null) => void;
getKey: (row: T) => string;
renderDetail: (row: T) => React.ReactNode;
title: (row: T) => string;
description: (row: T) => string | null | undefined;
meta: (row: T) => string | null | undefined;
}
function ResourcePane<T>({
rows,
loading,
error,
onRetry,
noun,
icon,
search,
filter,
function SkillLink({
skill,
selected,
onSelect,
getKey,
renderDetail,
title,
description,
meta,
}: ResourcePaneProps<T>) {
const { t } = useTranslation();
const [query, setQuery] = React.useState("");
const [chosen, setChosen] = React.useState<string[]>([]);
const options = [...new Set(rows.flatMap(filter.values))].sort();
const sorted = [...rows].sort((a, b) => title(a).localeCompare(title(b)));
const shown = sorted.filter(
(row) =>
matchesSearchTerm(query, search(row)) &&
(chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))),
);
const filtered = query.trim() !== "" || chosen.length > 0;
const clear = () => {
setQuery("");
setChosen([]);
}: {
skill: Plugin;
selected: string | null;
onSelect: (key: string | null) => void;
}) {
const hrefFor = useOpenHref();
const follow = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.defaultPrevented) return;
if (event.metaKey || event.ctrlKey) return;
if (event.shiftKey || event.altKey) return;
event.preventDefault();
onSelect(skill.name);
};
return (
<HubMasterDetail
rows={shown}
loading={loading}
selectedKey={selected}
opened={rows.find((row) => getKey(row) === selected)}
getKey={getKey}
onSelect={onSelect}
empty={
error ? (
<LoadFailed noun={noun} onRetry={onRetry} />
) : (
<NoResults icon={icon} noun={noun} query={query} onClear={filtered ? clear : undefined} />
)
}
toolbar={
<div className="flex items-center gap-2">
<a
href={hrefFor(skill.name)}
aria-current={selected === skill.name ? "true" : undefined}
className="flex min-w-0 flex-col gap-1 rounded-sm py-2 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={follow}
>
<span className="font-mono text-sm font-medium whitespace-normal wrap-anywhere">{skill.name}</span>
{skill.description && (
<span className="line-clamp-2 text-xs whitespace-normal text-muted-foreground">{skill.description}</span>
)}
{skill.domain && <span className="text-xs whitespace-normal text-muted-foreground">{skill.domain}</span>}
</a>
);
}
export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }: SkillsPaneProps) {
const { t } = useTranslation();
const [query, setQuery] = React.useState("");
const [domains, setDomains] = React.useState<string[]>([]);
const skills = React.useMemo(() => [...(rows ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [rows]);
const options = React.useMemo(
() =>
[...new Set(skills.flatMap((skill) => (skill.domain ? [skill.domain] : [])))].sort().map((domain) => ({
domain,
count: skills.filter((skill) => skill.domain === domain).length,
})),
[skills],
);
const shown = React.useMemo(
() =>
skills.filter((skill) => {
const matchesQuery = matchesSearchTerm(query, [
skill.name,
skill.description,
skill.domain,
skill.namespace,
...(skill.keywords ?? []),
]);
const matchesDomain = domains.length === 0 || domains.includes(skill.domain ?? "");
return matchesQuery && matchesDomain;
}),
[skills, query, domains],
);
const opened = skills.find((skill) => skill.name === selected);
const missingName = opened === undefined && !loading ? selected : null;
const showRefreshError = error && !loading && shown.length > 0;
const filtered = query.trim() !== "" || domains.length > 0;
const clear = () => {
setQuery("");
setDomains([]);
};
const columns: ColumnDef<Plugin>[] = [
{
accessorKey: "name",
header: t("publicModelHub.catalog.skill", { defaultValue: "Skill" }),
meta: { skeleton: "twoLine", className: "min-w-48 whitespace-normal" },
cell: ({ row }) => <SkillLink skill={row.original} selected={selected} onSelect={onSelect} />,
},
{
id: "source",
header: t("publicModelHub.catalog.source", { defaultValue: "Source" }),
meta: {
className: "hidden max-w-64 text-xs whitespace-normal wrap-anywhere text-muted-foreground lg:table-cell",
},
cell: ({ row }) => getSourceDisplayText(row.original.source),
},
{
accessorKey: "namespace",
header: t("publicModelHub.catalog.namespace", { defaultValue: "Namespace" }),
meta: {
className:
"hidden max-w-40 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground xl:table-cell",
},
cell: ({ row }) => row.original.namespace || "-",
},
{
accessorKey: "category",
header: t("publicModelHub.catalog.category", { defaultValue: "Category" }),
meta: { className: "max-w-40 text-xs whitespace-normal wrap-anywhere text-muted-foreground" },
cell: ({ row }) => row.original.category || "-",
},
{
accessorKey: "version",
header: t("publicModelHub.catalog.version", { defaultValue: "Version" }),
meta: {
className:
"hidden max-w-32 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground sm:table-cell",
},
cell: ({ row }) => row.original.version || "-",
},
];
const countOptions = {
count: shown.length,
defaultValue_one: "{{count}} skill",
defaultValue_other: "{{count}} skills",
defaultValue: "{{count}} skills",
};
return (
<div data-slot="catalog-layout" className="grid min-w-0 gap-8 lg:grid-cols-[220px_minmax(0,1fr)]">
<CatalogFilters count={domains.length} onClear={() => setDomains([])}>
<fieldset data-slot="catalog-filter-section" className="min-w-0">
<legend className="mb-3 text-sm font-medium">
{t("publicModelHub.catalog.domain", { defaultValue: "Domain" })}
</legend>
<div className="flex flex-col gap-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
aria-pressed={domains.length === 0}
onClick={() => setDomains([])}
>
{t("publicModelHub.catalog.allDomains", { defaultValue: "All domains" })}
<span className="text-xs text-muted-foreground tabular-nums">{loading ? "-" : skills.length}</span>
</Button>
{options.map(({ domain, count }) => (
<label key={domain} className="flex cursor-pointer items-center gap-2.5 text-sm">
<Checkbox
checked={domains.includes(domain)}
onCheckedChange={(checked) =>
setDomains((current) =>
checked ? [...current, domain] : current.filter((value) => value !== domain),
)
}
/>
<span className="min-w-0 flex-1 wrap-anywhere">{domain}</span>
<span aria-hidden="true" className="text-xs text-muted-foreground tabular-nums">
{count}
</span>
</label>
))}
</div>
</fieldset>
</CatalogFilters>
<div data-slot="catalog-results" className="flex min-w-0 flex-col gap-4">
<div data-slot="catalog-toolbar" className="flex flex-wrap items-center gap-3">
<SearchInput
value={query}
onChange={setQuery}
placeholder={t("publicModelHub.resourcePane.search", { noun })}
placeholder={t("publicModelHub.catalog.searchSkills", { defaultValue: "Search skills" })}
/>
{options.length > 0 && (
<FilterChip
label={filter.label}
noun={filter.noun}
options={options.map((option) => ({ value: option, label: option }))}
value={chosen}
onChange={setChosen}
/>
{filtered && (
<Button variant="ghost" size="sm" onClick={clear}>
{t("publicModelHub.catalog.clearAll", { defaultValue: "Clear all" })}
</Button>
)}
</div>
}
renderItem={(row) => (
<>
<ItemMedia variant="icon" className="size-5 text-muted-foreground">
{icon}
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<RowTitle>{title(row)}</RowTitle>
{description(row) && <ItemDescription>{description(row)}</ItemDescription>}
</ItemContent>
{meta(row) && <ItemActions className="text-xs text-muted-foreground">{meta(row)}</ItemActions>}
</>
)}
renderDetail={renderDetail}
/>
);
}
interface PaneProps<T> {
rows: T[] | undefined;
loading: boolean;
error: boolean;
onRetry: () => void;
selected: string | null;
onSelect: (key: string | null) => void;
}
export function AgentsPane({ rows, ...props }: PaneProps<AgentCard>) {
const { t } = useTranslation();
return (
<ResourcePane<AgentCard>
{...props}
rows={rows ?? []}
noun={t("publicModelHub.agentsPane.noun")}
icon={<Bot />}
search={(agent) => [agent.name, agent.description]}
filter={{
label: t("publicModelHub.agentsPane.filterLabel"),
noun: t("publicModelHub.agentsPane.filterNoun"),
values: (agent) => (agent.skills ?? []).flatMap((s) => s.tags ?? []),
}}
getKey={(agent) => agent.name}
title={(agent) => agent.name}
description={(agent) => agent.description}
meta={(agent) => (agent.version ? `v${agent.version}` : undefined)}
renderDetail={(agent) => <AgentInspector agent={agent} />}
/>
);
}
export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
const { t } = useTranslation();
return (
<ResourcePane<MCPServerData>
{...props}
rows={rows ?? []}
noun={t("publicModelHub.mcpPane.noun")}
icon={<Server />}
search={(server) => [server.name, server.mcp_info?.description]}
filter={{
label: t("publicModelHub.mcpPane.filterLabel"),
noun: t("publicModelHub.mcpPane.filterNoun"),
values: (server) => (server.transport ? [server.transport] : []),
}}
getKey={(server) => server.name}
title={(server) => server.name}
description={(server) => server.mcp_info?.description}
meta={(server) => (server.auth_type ? mcpAuthLabel(server.auth_type) : undefined)}
renderDetail={(server) => <McpInspector server={server} />}
/>
);
}
export function SkillsPane({ rows, ...props }: PaneProps<Plugin>) {
const { t } = useTranslation();
return (
<ResourcePane<Plugin>
{...props}
rows={rows ?? []}
noun={t("publicModelHub.skillsPane.noun")}
icon={<Puzzle />}
search={(skill) => [skill.name, skill.description, skill.namespace, skill.domain, ...(skill.keywords ?? [])]}
filter={{
label: t("publicModelHub.skillsPane.filterLabel"),
noun: t("publicModelHub.skillsPane.filterNoun"),
values: (skill) => (skill.domain ? [skill.domain] : []),
}}
getKey={(skill) => skill.name}
title={(skill) => skill.name}
description={(skill) => skill.description}
meta={(skill) => skill.domain}
renderDetail={(skill) => <SkillInspector skill={skill} />}
/>
<div data-slot="catalog-count" className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
{loading
? t("publicModelHub.catalog.loadingSkills", { defaultValue: "Loading skills…" })
: !error && t("publicModelHub.catalog.skillCount", countOptions)}
</div>
{showRefreshError && <LoadFailed noun={t("publicModelHub.skillsPane.noun")} onRetry={onRetry} />}
<div data-slot="catalog-table" aria-busy={loading}>
<DataTable
data={shown}
columns={columns}
getRowId={(skill) => skill.name}
isLoading={loading}
loadingMessage={t("publicModelHub.catalog.loadingSkills", { defaultValue: "Loading skills…" })}
isError={error}
errorMessage={t("publicModelHub.loadFailed.title", { noun: t("publicModelHub.skillsPane.noun") })}
onRetry={onRetry}
rowClassName={(row) => (row.original.name === selected ? "bg-muted/50" : "")}
noDataMessage={
<NoResults
icon={<Puzzle />}
noun={t("publicModelHub.skillsPane.noun")}
query={query}
onClear={filtered ? clear : undefined}
/>
}
/>
</div>
</div>
<CatalogDetails name={selected} loading={loading && opened === undefined} onClose={() => onSelect(null)}>
{opened && <SkillInspector skill={opened} />}
{missingName !== null && !error && <NotListed name={missingName} />}
{missingName !== null && error && <LoadFailed noun={t("publicModelHub.skillsPane.noun")} onRetry={onRetry} />}
</CatalogDetails>
</div>
);
}

View file

@ -0,0 +1,268 @@
.catalog {
--catalog-blue: #343bff;
--catalog-rule: #dce1ed;
--catalog-muted: #67728a;
min-height: 100dvh;
gap: 0;
background: #f7f8fb;
color: #1e2534;
}
:global(.dark) .catalog {
--catalog-blue: #9ca5ff;
--catalog-rule: #303747;
--catalog-muted: #a6afc1;
background: var(--background);
color: var(--foreground);
}
.catalog > nav {
background: inherit;
border-color: var(--catalog-rule);
}
.catalog > nav > div {
max-width: 1504px;
margin-inline: auto;
}
.catalog > nav > div > div {
height: 76px;
padding-inline: 36px;
gap: 36px;
}
.navigation {
align-self: stretch;
height: auto !important;
gap: 24px;
padding: 0;
}
.navigation [role="tab"] {
flex: none;
height: 100%;
padding: 0;
border-radius: 0;
font-size: 13px;
}
.navigation [role="tab"][data-active] {
color: var(--catalog-blue);
}
.navigation [role="tab"]::after {
bottom: -1px;
background: var(--catalog-blue);
}
.main {
width: 100%;
max-width: 1504px;
margin-inline: auto;
padding: 44px 36px 48px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
margin-bottom: 40px;
}
.eyebrow {
margin-bottom: 16px;
color: var(--catalog-muted);
font-family: var(--font-mono), monospace;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.title {
margin-bottom: 10px;
font-family: var(--font-catalog-display), sans-serif;
font-size: clamp(48px, 6vw, 80px);
font-weight: 600;
line-height: 1.05;
letter-spacing: -0.02em;
text-transform: uppercase;
}
.description {
color: var(--catalog-muted);
font-size: 15px;
}
.connection {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
max-width: 100%;
padding-left: 24px;
border-left: 1px solid var(--catalog-rule);
}
.catalog [data-slot="catalog-layout"] {
display: grid;
grid-template-columns: 192px minmax(0, 1fr);
gap: 28px;
}
.catalog [data-slot="catalog-filters"] {
padding-right: 24px;
border-right: 1px solid var(--catalog-rule);
}
.catalog [data-slot="catalog-filter-section"] {
padding-block: 20px;
border-bottom: 1px solid var(--catalog-rule);
}
.catalog [data-slot="catalog-filter-section"]:last-child {
border-bottom: 0;
}
.catalog [data-slot="catalog-results"] {
min-width: 0;
}
.catalog [data-slot="catalog-toolbar"] {
margin-bottom: 18px;
}
.catalog [data-slot="catalog-count"] {
color: var(--catalog-muted);
font-family: var(--font-mono), monospace;
font-size: 11px;
}
.catalog [data-slot="catalog-table"] {
overflow: hidden;
border-radius: 8px;
background: var(--card);
}
.catalog [data-slot="catalog-table"] table {
font-size: 13px;
}
.catalog [data-slot="catalog-table"] th {
height: 44px;
color: var(--catalog-muted);
font-size: 11px;
font-weight: 400;
}
.catalog [data-slot="catalog-table"] td {
height: 76px;
padding-block: 16px;
}
.catalog [data-slot="catalog-table"] tr {
border-color: var(--catalog-rule);
}
.catalog [data-slot="catalog-table"] a:hover {
color: var(--catalog-blue);
}
.catalog [data-slot="catalog-results"] [data-slot="input-group"] {
min-height: 44px;
background: var(--card);
border-color: var(--catalog-rule);
box-shadow: none;
}
@media (max-width: 1023px) {
.catalog [data-slot="catalog-filters"] {
padding-right: 0;
border-right: 0;
}
.catalog > nav > div > div {
gap: 24px;
padding-inline: 24px;
}
.main {
padding-inline: 24px;
}
.header {
flex-wrap: wrap;
}
.connection {
padding-left: 0;
border-left: 0;
}
.catalog [data-slot="catalog-layout"] {
display: flex;
flex-direction: column;
gap: 4px;
}
}
@media (max-width: 639px) {
.catalog > nav > div > div {
height: auto;
min-height: 68px;
flex-wrap: wrap;
gap: 0 24px;
padding: 14px 20px 0;
}
.catalog > nav > div > div > :last-child {
display: none;
}
.navigation {
order: 3;
width: 100%;
height: 48px !important;
justify-content: flex-start;
margin-top: 4px;
}
.main {
padding: 28px 20px;
}
.header {
margin-bottom: 28px;
}
.eyebrow {
margin-bottom: 12px;
}
.description {
font-size: 13px;
}
.catalog [data-slot="catalog-layout"] {
display: flex;
flex-direction: column;
gap: 20px;
}
.catalog [data-slot="catalog-filters"] {
padding-right: 0;
border-right: 0;
}
.catalog [data-slot="catalog-filter-section"] {
padding-block: 12px;
}
.catalog [data-slot="catalog-table"] td {
height: 68px;
padding-block: 12px;
}
}
.catalog [data-slot="catalog-filter-section"] legend {
font-family: var(--font-mono), monospace;
color: var(--catalog-muted);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.catalog [data-slot="catalog-filter-section"] [aria-pressed="true"] {
color: var(--catalog-blue);
background: color-mix(in srgb, var(--catalog-blue) 7%, transparent);
}
.catalog [data-slot="catalog-table"] td:nth-child(n + 2) {
font-variant-numeric: tabular-nums;
}

View file

@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -6,7 +6,7 @@ import { useState } from "react";
import type { ResourceListPage } from "@/app/(dashboard)/hooks/common/useResourceList";
import type { Plugin } from "@/components/claude_code_plugins/types";
import { apiClient, getPublicModelHubInfo, getUiConfig } from "@/components/networking";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, usePublicModelHubList } from "./usePublicModelHubList";
@ -18,8 +18,6 @@ export interface HubInfo {
useful_links: Record<string, string | { url: string; index?: number }>;
}
const asArray = <T>(data: unknown): T[] => (Array.isArray(data) ? (data as T[]) : []);
const UI_CONFIG_QUERY = {
queryKey: ["publicAiHub", "uiConfig"],
queryFn: () => getUiConfig(),
@ -36,16 +34,6 @@ export function useHubData() {
queryFn: () => getPublicModelHubInfo(),
enabled: ready,
});
const agents = useQuery<AgentCard[]>({
queryKey: ["publicAiHub", "agents"],
queryFn: async ({ signal }) => asArray<AgentCard>(await apiClient.get("/public/agent_hub", { signal })),
enabled: ready,
});
const mcp = useQuery<MCPServerData[]>({
queryKey: ["publicAiHub", "mcp"],
queryFn: async ({ signal }) => asArray<MCPServerData>(await apiClient.get("/public/mcp_hub", { signal })),
enabled: ready,
});
const skills = useQuery<Plugin[]>({
queryKey: ["publicAiHub", "skills"],
queryFn: async ({ signal }) =>
@ -61,7 +49,7 @@ export function useHubData() {
const settledTotal = !listLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
if (settledTotal !== modelTotal) setModelTotal(settledTotal);
return { info, agents, mcp, skills, models, facets, modelTotal: settledTotal };
return { info, skills, models, facets, modelTotal: settledTotal };
}
export type HubData = ReturnType<typeof useHubData>;

View file

@ -1,29 +1,39 @@
"use client";
import { createSerializer, parseAsString, useQueryState } from "nuqs";
import { useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { createSerializer, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect } from "react";
import { useUrlTab } from "@/hooks/useUrlTab";
import { recordUiEvent } from "@/lib/telemetry/uiEvents";
import { routeSegmentForPathname } from "@/utils/uiHref";
export const HUB_TABS = ["models", "agents", "mcp", "skills"] as const;
export const HUB_TABS = ["models", "skills"] as const;
export type HubTab = (typeof HUB_TABS)[number];
const OPEN_PARAM = { open: parseAsString };
const HUB_PARAMS = {
tab: parseAsStringLiteral([...HUB_TABS, "agents", "mcp"] as const).withDefault("models"),
...OPEN_PARAM,
};
const serializeOpen = createSerializer(OPEN_PARAM);
export function useHubUrlState() {
const [tab, setUrlTab] = useUrlTab(HUB_TABS, "models");
const [open, setOpenParam] = useQueryState("open", OPEN_PARAM.open);
const setOpen = useCallback((key: string | null) => void setOpenParam(key), [setOpenParam]);
const [state, setState] = useQueryStates(HUB_PARAMS);
const page = routeSegmentForPathname(usePathname() ?? "") || "home";
const retired = state.tab === "agents" || state.tab === "mcp";
const tab: HubTab = state.tab === "skills" ? "skills" : "models";
useEffect(() => {
if (retired) void setState({ tab: null, open: null });
}, [retired, setState]);
const setOpen = useCallback((key: string | null) => void setState({ open: key }), [setState]);
const setTab = useCallback(
(next: HubTab) => {
setUrlTab(next);
setOpen(null);
void recordUiEvent({ page, action: "click", target: `tab=${next}` });
void setState({ tab: next, open: null });
},
[setUrlTab, setOpen],
[page, setState],
);
return { tab, setTab, open: open || null, setOpen };
return { tab, setTab, open: retired ? null : state.open || null, setOpen };
}
export function useOpenHref() {

View file

@ -6971,6 +6971,48 @@
}
},
"publicModelHub": {
"catalog": {
"modelHub": "Model Hub",
"skillsHub": "Skills Hub",
"eyebrow": "The LiteLLM catalog",
"modelsDescription": "Find pricing, context, and capabilities.",
"skillsDescription": "Discover reusable skills for your agents.",
"filters": "Filters",
"clearFilters": "Clear filters",
"provider": "Provider",
"capabilities": "Capabilities",
"mode": "Mode",
"model": "Model",
"inputLimit": "Input limit",
"inputPrice": "Input /1M",
"outputPrice": "Output /1M",
"searchModels": "Search models",
"loadingModels": "Loading models…",
"modelCount_one": "{{count}} model",
"modelCount_other": "{{count}} models",
"sort": {
"model_group": "Name",
"providers": "Provider",
"mode": "Mode",
"input_cost_per_token": "Input price",
"output_cost_per_token": "Output price",
"max_input_tokens": "Context",
"max_output_tokens": "Max output",
"rpm": "Rate limit"
},
"skill": "Skill",
"source": "Source",
"namespace": "Namespace",
"category": "Category",
"version": "Version",
"domain": "Domain",
"allDomains": "All domains",
"searchSkills": "Search skills",
"clearAll": "Clear all",
"loadingSkills": "Loading skills…",
"skillCount_one": "{{count}} skill",
"skillCount_other": "{{count}} skills"
},
"modelHubPage": {
"loading": "Loading..."
},