From 665c0dc5085cc57ac9213e7295dfcb8a529f941d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Jul 2026 00:02:00 -0700 Subject: [PATCH] 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 --- .../src/components/DashboardHeader.test.tsx | 6 +-- .../src/components/DashboardHeader.tsx | 17 +++---- .../components/Navbar/ViewSwitcher.test.tsx | 32 ++++++++++--- .../src/components/Navbar/ViewSwitcher.tsx | 45 ++++++++++--------- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index bc871085f48..aa83e70c56d 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -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(); - 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(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index a8537980dbf..8b928f8e6fe 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -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) {
- {showSwitcher ? ( - - - - ) : ( - section && {section} - )} - {(showSwitcher || section) && } + + + + {title} diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx index bdbf74cdf7d..d9a28b2ab8c 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx @@ -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(); - 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(); + + 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(); @@ -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(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index b4876f791eb..09a4538ae18 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -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: ( +
+ Chat + {isChatRoute && } +
+ ), + } + : { + key: CHAT, + disabled: true, + label: ( +
+ Chat + + Admins can enable in Settings + +
+ ), + }; + const items: MenuProps["items"] = [ ...modeEntries.map((e) => ({ key: e.key, @@ -47,19 +62,7 @@ export default function ViewSwitcher() { ), })), - ...(chatEnabled - ? [ - { - key: CHAT, - label: ( -
- Chat - {isChatRoute && } -
- ), - }, - ] - : []), + chatItem, ]; const onClick: MenuProps["onClick"] = ({ key }) => {