feat(ui): always show the gateway selector with a discoverable Chat entry

The AI Gateway selector now always renders at the breadcrumb root, even with no plugins and Chat UI disabled, so the Chat feature stays discoverable. The Chat entry is always listed: clickable when enabled, and disabled with an "Admins can enable in Settings" hint when it is off.

Since the selector is now unconditional, the useViewSwitcherVisible hook and the section-crumb fallback added in the previous commit are removed
This commit is contained in:
Yuneng Jiang 2026-07-11 00:02:00 -07:00
parent ba17c4526e
commit 665c0dc508
No known key found for this signature in database
4 changed files with 59 additions and 41 deletions

View file

@ -47,11 +47,11 @@ describe("DashboardHeader breadcrumb", () => {
await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument());
});
it("falls back to the static section crumb when the selector has nothing to switch to", () => {
it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => {
render(<DashboardHeader page="logs" />);
expect(screen.getByText("Observability")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /AI Gateway/i })).not.toBeInTheDocument();
expect(screen.queryByText("Observability")).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, { useViewSwitcherVisible } from "@/components/Navbar/ViewSwitcher";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@ -27,8 +27,7 @@ interface DashboardHeaderProps {
// Top bar for the dashboard shell. Sits only over the content column (the brand
// 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 { title } = getBreadcrumb(page);
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const hideCommunityLinks = useDisableShowPrompts();
@ -45,14 +44,10 @@ 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">
{showSwitcher ? (
<BreadcrumbItem className="flex-none">
<ViewSwitcher />
</BreadcrumbItem>
) : (
section && <BreadcrumbItem className="whitespace-nowrap">{section}</BreadcrumbItem>
)}
{(showSwitcher || section) && <BreadcrumbSeparator />}
<BreadcrumbItem className="flex-none">
<ViewSwitcher />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
</BreadcrumbItem>

View file

@ -49,9 +49,23 @@ describe("ViewSwitcher", () => {
state.setMode.mockClear();
});
it("renders nothing with no plugins, chat disabled, and a non-admin user", () => {
const { container } = render(<ViewSwitcher />);
expect(container.firstChild).toBeNull();
it("still renders the selector with a disabled Chat hint when there are no plugins and chat is off", async () => {
render(<ViewSwitcher />);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("AI Gateway");
act(() => {
fireEvent.click(button);
});
await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument());
expect(screen.getByText(/Admins can enable in Settings/i)).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).not.toHaveBeenCalled();
expect(state.setMode).not.toHaveBeenCalled();
});
it("labels the button from the active plugin and lists AI Gateway + each plugin", async () => {
@ -95,7 +109,7 @@ describe("ViewSwitcher", () => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument());
expect(screen.queryByText(/Enable in Admin Settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Admins can enable in Settings/i)).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
@ -121,7 +135,7 @@ describe("ViewSwitcher", () => {
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("hides the Chat entry from everyone when disabled", async () => {
it("shows Chat as a disabled, non-navigating entry with an admin hint when disabled", async () => {
state.enableChatUI = false;
state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }];
render(<ViewSwitcher />);
@ -130,6 +144,12 @@ describe("ViewSwitcher", () => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("Observability")).toBeInTheDocument());
expect(screen.queryByText("Chat")).not.toBeInTheDocument();
expect(screen.getByText("Chat")).toBeInTheDocument();
expect(screen.getByText(/Admins can enable in Settings/i)).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).not.toHaveBeenCalled();
});
});

View file

@ -11,12 +11,6 @@ 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();
@ -24,8 +18,6 @@ export default function ViewSwitcher() {
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
if (plugins.length === 0 && !chatEnabled) return null;
const chatHref = migratedHref(CHAT);
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
@ -37,6 +29,29 @@ export default function ViewSwitcher() {
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
];
const chatItem = chatEnabled
? {
key: CHAT,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <CheckOutlined className="text-blue-600" />}
</div>
),
}
: {
key: CHAT,
disabled: true,
label: (
<div className="flex max-w-[220px] flex-col py-0.5">
<span className="font-medium">Chat</span>
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
</span>
</div>
),
};
const items: MenuProps["items"] = [
...modeEntries.map((e) => ({
key: e.key,
@ -47,19 +62,7 @@ export default function ViewSwitcher() {
</div>
),
})),
...(chatEnabled
? [
{
key: CHAT,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <CheckOutlined className="text-blue-600" />}
</div>
),
},
]
: []),
chatItem,
];
const onClick: MenuProps["onClick"] = ({ key }) => {