feat(ui): root the gateway breadcrumb in the AI Gateway selector

The AI Gateway select (ViewSwitcher) now sits at the root of the DashboardHeader breadcrumb instead of on the right, so the top bar reads [AI Gateway select] > Page to match the redesign. It keeps the same dropdown, including the Chat / Chat UI options.

When no plugins are registered and Chat UI is disabled there is nothing to switch between, so the breadcrumb falls back to the static section crumb rather than rendering a dangling leading separator
This commit is contained in:
Yuneng Jiang 2026-07-10 23:25:19 -07:00
parent c15891fc98
commit ba17c4526e
No known key found for this signature in database
3 changed files with 80 additions and 13 deletions

View file

@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DashboardHeader } from "./DashboardHeader";
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
plugins: [] as { name: string; display_name: string; url: string }[],
enableChatUI: false,
};
return {
state,
mockUsePluginMode: vi.fn(() => ({ mode: "ai-gateway", setMode: vi.fn(), plugins: state.plugins })),
mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } } })),
};
});
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/" }));
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` }));
vi.mock("@/hooks/useWorker", () => ({ useWorker: () => ({ isControlPlane: false, selectedWorker: null }) }));
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => false }));
vi.mock("@/components/Navbar/BlogDropdown/BlogDropdown", () => ({ BlogDropdown: () => null }));
vi.mock("@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () => ({
CommunityEngagementButtons: () => null,
}));
vi.mock("@/components/Navbar/NotificationsBell/NotificationsBell", () => ({ NotificationsBell: () => null }));
vi.mock("@/components/Navbar/WorkerDropdown/WorkerDropdown", () => ({ default: () => null }));
describe("DashboardHeader breadcrumb", () => {
afterEach(() => {
state.plugins = [];
state.enableChatUI = false;
});
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {
state.enableChatUI = true;
render(<DashboardHeader page="logs" />);
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
const selector = screen.getByRole("button", { name: /AI Gateway/i });
act(() => {
fireEvent.click(selector);
});
await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument());
});
it("falls back to the static section crumb when the selector has nothing to switch to", () => {
render(<DashboardHeader page="logs" />);
expect(screen.getByText("Observability")).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /AI Gateway/i })).not.toBeInTheDocument();
});
});

View file

@ -13,7 +13,7 @@ import { getBreadcrumb } from "@/components/leftnav";
import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ViewSwitcher, { useViewSwitcherVisible } from "@/components/Navbar/ViewSwitcher";
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@ -28,6 +28,7 @@ interface DashboardHeaderProps {
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
export function DashboardHeader({ page }: DashboardHeaderProps) {
const { section, title } = getBreadcrumb(page);
const showSwitcher = useViewSwitcherVisible();
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const hideCommunityLinks = useDisableShowPrompts();
@ -44,12 +45,14 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
{section && (
<>
<BreadcrumbItem className="whitespace-nowrap">{section}</BreadcrumbItem>
<BreadcrumbSeparator />
</>
{showSwitcher ? (
<BreadcrumbItem className="flex-none">
<ViewSwitcher />
</BreadcrumbItem>
) : (
section && <BreadcrumbItem className="whitespace-nowrap">{section}</BreadcrumbItem>
)}
{(showSwitcher || section) && <BreadcrumbSeparator />}
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
</BreadcrumbItem>
@ -76,8 +79,6 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
{!hideCommunityLinks && <CommunityEngagementButtons />}
<Separator orientation="vertical" className="mx-1.5 h-5" />
<NotificationsBell />
<Separator orientation="vertical" className="mx-1.5 h-5" />
<ViewSwitcher />
</div>
</header>
);

View file

@ -1,7 +1,8 @@
import React from "react";
import { usePathname } from "next/navigation";
import { Dropdown } from "antd";
import { AppstoreOutlined, CheckOutlined, DownOutlined } from "@ant-design/icons";
import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons";
import { ChevronsUpDown } from "lucide-react";
import type { MenuProps } from "antd";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
@ -10,6 +11,12 @@ import { migratedHref } from "@/utils/migratedPages";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
export function useViewSwitcherVisible(): boolean {
const { plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
return plugins.length > 0 || Boolean(uiSettings?.values?.enable_chat_ui);
}
export default function ViewSwitcher() {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
@ -72,11 +79,13 @@ export default function ViewSwitcher() {
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-gray-200 px-2.5 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<AppstoreOutlined className="text-gray-500" />
<span>{activeLabel}</span>
<DownOutlined className="text-[10px] text-gray-400" />
<span className="flex size-5 flex-none items-center justify-center rounded bg-muted text-muted-foreground">
<AppstoreOutlined className="text-[13px]" />
</span>
<span className="truncate">{activeLabel}</span>
<ChevronsUpDown className="size-3.5 flex-none text-muted-foreground" />
</button>
</Dropdown>
);