fix(ui): surface hub load errors and keep the open item steady

Agents, MCP servers and skills now load through apiClient so an HTTP error
shows the inline error instead of an empty "published yet" state, and an
all-failing gateway reaches the unavailable page. Try again shows loading
instead of an empty hub, the open model stays in the details pane when a
search or page change hides its row, a zero-result search keeps the pane,
phones get the not-listed sheet for a stale link, an empty open param means
nothing opened, free models read Free in the list, and an invalid skill date
is hidden
This commit is contained in:
Yuneng Jiang 2026-10-31 15:23:30 -07:00
parent e980378056
commit 0da546e11a
No known key found for this signature in database
6 changed files with 212 additions and 84 deletions

View file

@ -99,6 +99,33 @@ function Rows<T>({
);
}
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 ?? "Details",
body: row ? renderDetail(row) : content.missingKey && <NotListed name={content.missingKey} />,
};
}
export function HubMasterDetail<T>({
switcher,
toolbar,
@ -129,11 +156,12 @@ export function HubMasterDetail<T>({
footer?: React.ReactNode;
}) {
const narrow = useIsNarrow();
const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey);
const selected = selectedKey === null ? rows[0] : chosen;
const [sheetRow, setSheetRow] = React.useState<T | undefined>(chosen);
if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen);
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} />;
@ -158,23 +186,19 @@ export function HubMasterDetail<T>({
<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]",
!isEmpty && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
!listOnly && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
)}
>
<div className={cn("flex min-h-0 flex-col", !isEmpty && "lg:border-r lg:border-border")}>
<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>
{!isEmpty && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
<DetailSheet
open={narrow && chosen !== undefined}
title={sheetRow ? getKey(sheetRow) : "Details"}
onClose={() => onSelect(null)}
>
{sheetRow && renderDetail(sheetRow)}
{!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>
);

View file

@ -148,10 +148,15 @@ function ModelsToolbar({ hub }: { hub: HubData }) {
);
}
const prices = (model: ModelGroupInfo) => [
model.input_cost_per_token ? `${formatPerMillion(model.input_cost_per_token)} / 1M in` : null,
model.output_cost_per_token ? `${formatPerMillion(model.output_cost_per_token)} / 1M out` : null,
];
const price = (cost: number | null | undefined, direction: "input" | "output") => {
if (cost === 0) return `Free ${direction}`;
return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null;
};
const prices = (model: ModelGroupInfo) =>
model.input_cost_per_token === 0 && model.output_cost_per_token === 0
? ["Free", null]
: [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")];
function ModelRow({ model }: { model: ModelGroupInfo }) {
const [input, output] = prices(model);

View file

@ -1,14 +1,13 @@
import { fireEvent } from "@testing-library/react";
import { fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { agentHubPublicModelsCall, mcpHubPublicServersCall, skillHubPublicCall } from "@/components/networking";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import PublicAiHub from "./PublicAiHub";
const { apiGetMock, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
apiGetMock: vi.fn(),
realGet: { current: (..._args: unknown[]): Promise<unknown> => Promise.reject(new Error("unset")) },
navigation: { params: new URLSearchParams() },
uiSettings: { values: {} as Record<string, unknown> },
replaceMock: vi.fn(),
@ -36,26 +35,13 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
vi.mock("@/components/networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/networking")>();
realGet.current = actual.apiClient.get;
return {
...actual,
apiClient: { ...actual.apiClient, get: apiGetMock },
getUiConfig: vi.fn().mockResolvedValue({}),
getProxyBaseUrl: vi.fn(() => "https://gateway.example.com"),
getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo),
agentHubPublicModelsCall: vi.fn().mockResolvedValue([
{
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: [],
},
]),
mcpHubPublicServersCall: vi.fn().mockResolvedValue([]),
skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }),
};
});
@ -69,9 +55,35 @@ const MODELS = [
input_cost_per_token: 0.000003,
description: "Best for agentic coding",
},
{ model_group: "gpt-5", providers: ["openai"], mode: "chat", description: "General purpose" },
{
model_group: "gpt-5",
providers: ["openai"],
mode: "chat",
input_cost_per_token: 0,
output_cost_per_token: 0,
description: "General purpose",
},
];
const AGENTS = [
{
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: [],
},
];
const HUBS: Record<string, unknown> = {
"/public/agent_hub": AGENTS,
"/public/mcp_hub": [],
"/public/skill_hub": { plugins: [] },
};
const page = (data: unknown[]) => ({
data,
meta: { total_count: data.length, page: 1, page_size: 50, total_pages: 1 },
@ -80,10 +92,32 @@ const page = (data: unknown[]) => ({
const OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" };
const respondWithModels = () =>
apiGetMock.mockImplementation((path: string, options?: { query?: Record<string, unknown> }) => {
if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } });
return Promise.resolve(options?.query?.q === "o1-pro" ? page([OFF_PAGE_MODEL]) : page(MODELS));
type Query = { query?: Record<string, unknown> };
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 } });
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))));
};
const respondWithModels = () => apiGetMock.mockImplementation(respond);
const failModelsWith = (next: (options?: Query) => Promise<unknown>) =>
apiGetMock.mockImplementation((path: string, options?: Query) =>
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) => {
@ -91,19 +125,8 @@ const tokenExpiringIn = (seconds: number) => {
return `${encode({ alg: "HS256" })}.${encode({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`;
};
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}),
});
});
beforeEach(() => {
narrowScreen(false);
vi.clearAllMocks();
testQueryClient.clear();
uiSettings.values = {};
@ -167,7 +190,7 @@ describe("PublicAiHub", () => {
it("keeps agents reachable when only the models call fails", async () => {
const user = userEvent.setup();
apiGetMock.mockRejectedValue(new Error("No models configured"));
failModelsWith(() => Promise.reject(new Error("No models configured")));
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("Couldn't load models")).toBeInTheDocument();
@ -178,15 +201,92 @@ describe("PublicAiHub", () => {
it("says the hub is unavailable when every hub call fails", async () => {
apiGetMock.mockRejectedValue(new Error("Not found"));
vi.mocked(agentHubPublicModelsCall).mockRejectedValueOnce(new Error("Not found"));
vi.mocked(mcpHubPublicServersCall).mockRejectedValueOnce(new Error("Not found"));
vi.mocked(skillHubPublicCall).mockRejectedValueOnce(new Error("Not found"));
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("shows a load error when a hub answers with an HTTP error status", 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),
);
renderWithProviders(<PublicAiHub accessToken={null} />);
await user.click(await screen.findByRole("tab", { name: /^Agents/ }));
expect(await screen.findByText("Couldn't load agents")).toBeInTheDocument();
expect(screen.queryByText("No agents published yet")).not.toBeInTheDocument();
});
it("shows loading rather than an empty hub while Try again reloads models", async () => {
const user = userEvent.setup();
let finishRetry: (value: unknown) => void = () => {};
failModelsWith(() => Promise.reject(new Error("Gateway timeout")));
renderWithProviders(<PublicAiHub accessToken={null} />);
await screen.findByText("Couldn't load models");
failModelsWith(() => new Promise((resolve) => (finishRetry = resolve)));
await user.click(screen.getByRole("button", { name: "Try again" }));
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();
});
it("keeps the open model and its snippet tab while a search hides its row", async () => {
const user = userEvent.setup();
failModelsWith((options) =>
options?.query?.q === "gpt-5" ? new Promise(() => {}) : respond(MODEL_HUB_PATH, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "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(screen.queryByRole("link", { name: /gpt-5/ })).not.toBeInTheDocument());
expect(screen.getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "cURL" })).toHaveAttribute("aria-selected", "true");
});
it("keeps the open item's details when a search matches nothing", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, {
searchParams: { tab: "agents", open: "research-assistant" },
});
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();
});
it("marks a free model as free in its row", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />);
const row = await screen.findByRole("link", { name: /gpt-5/ });
expect(within(row).getAllByText("Free").length).toBeGreaterThan(0);
});
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();
});
it("tells phone visitors when a shared link names something no longer listed", async () => {
narrowScreen(true);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getByText("\u201cretired-model\u201d isn't listed here")).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" } });

View file

@ -30,7 +30,8 @@ function sourceValue(source: Plugin["source"]) {
export function SkillInspector({ skill }: { skill: Plugin }) {
const keywords = skill.keywords ?? [];
const added = skill.created_at ? new Date(skill.created_at).toLocaleDateString() : null;
const addedAt = skill.created_at ? new Date(skill.created_at) : null;
const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null;
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />

View file

@ -5,14 +5,7 @@ import { useState } from "react";
import type { ResourceListPage } from "@/app/(dashboard)/hooks/common/useResourceList";
import type { Plugin } from "@/components/claude_code_plugins/types";
import {
agentHubPublicModelsCall,
apiClient,
getPublicModelHubInfo,
getUiConfig,
mcpHubPublicServersCall,
skillHubPublicCall,
} from "@/components/networking";
import { apiClient, getPublicModelHubInfo, getUiConfig } from "@/components/networking";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
@ -45,25 +38,24 @@ export function useHubData() {
});
const agents = useQuery<AgentCard[]>({
queryKey: ["publicAiHub", "agents"],
queryFn: async () => asArray<AgentCard>(await agentHubPublicModelsCall()),
queryFn: async ({ signal }) => asArray<AgentCard>(await apiClient.get("/public/agent_hub", { signal })),
enabled: ready,
});
const mcp = useQuery<MCPServerData[]>({
queryKey: ["publicAiHub", "mcp"],
queryFn: async () => asArray<MCPServerData>(await mcpHubPublicServersCall()),
queryFn: async ({ signal }) => asArray<MCPServerData>(await apiClient.get("/public/mcp_hub", { signal })),
enabled: ready,
});
const skills = useQuery<Plugin[]>({
queryKey: ["publicAiHub", "skills"],
queryFn: async () => (await skillHubPublicCall()).plugins ?? [],
queryFn: async ({ signal }) =>
(await apiClient.get<{ plugins?: Plugin[] }>("/public/skill_hub", { signal })).plugins ?? [],
enabled: ready,
});
const list = usePublicModelHubList(ready);
const listLoading = !ready || list.isLoading;
const [listSettled, setListSettled] = useState(false);
if (!listLoading && !listSettled) setListSettled(true);
const models = { ...list, isLoading: listLoading && !listSettled };
const models = { ...list, isLoading: listLoading && list.rows.length === 0 };
const facets = usePublicModelHubFacets(ready);
const [modelTotal, setModelTotal] = useState<number | undefined>(undefined);
const settledTotal = !listLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
@ -74,17 +66,23 @@ export function useHubData() {
export type HubData = ReturnType<typeof useHubData>;
const openedModelQuery = (open: string | null, enabled: boolean) => ({
queryKey: ["publicAiHub", "openedModel", open],
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiClient.get<ResourceListPage<ModelGroupInfo>>(PUBLIC_MODEL_HUB_PATH, {
query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE },
signal,
}),
enabled,
retry: false,
});
export function useOpenedModel(open: string | null, rows: ModelGroupInfo[], enabled: boolean) {
const onPage = rows.find((model) => model.model_group === open);
const lookup = useQuery({
queryKey: ["publicAiHub", "openedModel", open],
queryFn: ({ signal }) =>
apiClient.get<ResourceListPage<ModelGroupInfo>>(PUBLIC_MODEL_HUB_PATH, {
query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE },
signal,
}),
enabled: enabled && open !== null && onPage === undefined,
});
const found = lookup.data?.data.find((model) => model.model_group === open);
return { model: onPage ?? found, pending: onPage === undefined && lookup.isLoading };
const [kept, setKept] = useState<ModelGroupInfo | undefined>(undefined);
const known = onPage ?? (kept?.model_group === open ? kept : undefined);
const lookup = useQuery(openedModelQuery(open, enabled && open !== null && known === undefined));
const model = known ?? lookup.data?.data.find((found) => found.model_group === open);
if (model !== undefined && model !== kept) setKept(model);
return { model, pending: model === undefined && lookup.isLoading };
}

View file

@ -23,7 +23,7 @@ export function useHubUrlState() {
},
[setUrlTab, setOpen],
);
return { tab, setTab, open, setOpen };
return { tab, setTab, open: open || null, setOpen };
}
export function useOpenHref() {