fix(ui): make mobile sidebar and top bar responsive (#44603)

* fix(ui): improve gateway layouts on mobile

* fix(ui): limit mobile cleanup to navigation and header

* fix(ui): share cached settings with mobile navigation
This commit is contained in:
moe-berri 2026-10-05 10:56:08 -07:00 • committed by GitHub
parent 8364f88cbb
commit 3b2ed83152
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 328 additions and 84 deletions

View file

@ -0,0 +1,45 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { expect, it, vi } from "vitest";
import Sidebar from "@/components/leftnav";
import SidebarProvider from "./SidebarProvider";
const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }),
}));
vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings }));
vi.mock("@/components/leftnav", () => ({
default: ({ enableProjectsUI }: ComponentProps<typeof Sidebar>) => (
<nav>{enableProjectsUI && <a href="/projects">Projects</a>}</nav>
),
}));
it("reuses loaded navigation settings immediately when the mobile sidebar mounts again", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const shell = (mobileOpen: boolean) => (
<QueryClientProvider client={client}>
<section aria-label="Desktop navigation">
<SidebarProvider sidebarCollapsed={false} />
</section>
{mobileOpen && (
<section aria-label="Mobile navigation">
<SidebarProvider sidebarCollapsed={false} />
</section>
)}
</QueryClientProvider>
);
const { rerender } = render(shell(false));
expect(await screen.findByRole("link", { name: "Projects" })).toBeVisible();
for (const open of [true, false, true]) {
rerender(shell(open));
if (open) {
expect(
within(screen.getByRole("region", { name: "Mobile navigation" })).getByRole("link", { name: "Projects" }),
).toBeVisible();
}
}
expect(getSettings).toHaveBeenCalledTimes(1);
});

View file

@ -1,9 +1,7 @@
"use client";
import Sidebar from "@/components/leftnav";
import { getUISettings } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useEffect, useState } from "react";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
interface SidebarProviderProps {
sidebarCollapsed: boolean;
@ -11,66 +9,19 @@ interface SidebarProviderProps {
}
const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProviderProps) => {
const { accessToken } = useAuthorized();
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
const [enableProjectsUI, setEnableProjectsUI] = useState<boolean>(false);
const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState<boolean>(false);
const [allowAgentsForTeamAdmins, setAllowAgentsForTeamAdmins] = useState<boolean>(false);
const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState<boolean>(false);
const [allowVectorStoresForTeamAdmins, setAllowVectorStoresForTeamAdmins] = useState<boolean>(false);
useEffect(() => {
const fetchUISettings = async () => {
if (!accessToken) {
return;
}
try {
const settings = await getUISettings(accessToken);
// API returns 'values' not 'settings'
if (settings?.values?.enabled_ui_pages_internal_users !== undefined) {
setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users);
} else {
}
if (settings?.values?.enable_projects_ui !== undefined) {
setEnableProjectsUI(Boolean(settings.values.enable_projects_ui));
}
if (settings?.values?.disable_agents_for_internal_users !== undefined) {
setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users));
}
if (settings?.values?.allow_agents_for_team_admins !== undefined) {
setAllowAgentsForTeamAdmins(Boolean(settings.values.allow_agents_for_team_admins));
}
if (settings?.values?.disable_vector_stores_for_internal_users !== undefined) {
setDisableVectorStoresForInternalUsers(Boolean(settings.values.disable_vector_stores_for_internal_users));
}
if (settings?.values?.allow_vector_stores_for_team_admins !== undefined) {
setAllowVectorStoresForTeamAdmins(Boolean(settings.values.allow_vector_stores_for_team_admins));
}
} catch (error) {
console.error("[SidebarProvider] Failed to fetch UI settings:", error);
}
};
fetchUISettings();
}, [accessToken]);
const { data: settings } = useUISettings();
const values = settings?.values;
return (
<Sidebar
collapsed={sidebarCollapsed}
onToggleCollapsed={onToggleCollapsed}
enabledPagesInternalUsers={enabledPagesInternalUsers}
enableProjectsUI={enableProjectsUI}
disableAgentsForInternalUsers={disableAgentsForInternalUsers}
allowAgentsForTeamAdmins={allowAgentsForTeamAdmins}
disableVectorStoresForInternalUsers={disableVectorStoresForInternalUsers}
allowVectorStoresForTeamAdmins={allowVectorStoresForTeamAdmins}
enabledPagesInternalUsers={values?.enabled_ui_pages_internal_users ?? null}
enableProjectsUI={Boolean(values?.enable_projects_ui)}
disableAgentsForInternalUsers={Boolean(values?.disable_agents_for_internal_users)}
allowAgentsForTeamAdmins={Boolean(values?.allow_agents_for_team_admins)}
disableVectorStoresForInternalUsers={Boolean(values?.disable_vector_stores_for_internal_users)}
allowVectorStoresForTeamAdmins={Boolean(values?.allow_vector_stores_for_team_admins)}
/>
);
};

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { AuthProvider } from "@/contexts/AuthContext";
import Layout from "./layout";
@ -19,12 +19,17 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({
}));
vi.mock("@/components/DashboardHeader", () => ({
DashboardHeader: () => <div data-testid="dashboard-header" />,
DashboardHeader: ({ navigationTrigger }: { navigationTrigger?: React.ReactNode }) => (
<div data-testid="dashboard-header">{navigationTrigger}</div>
),
}));
vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({
default: ({ sidebarCollapsed }: { sidebarCollapsed: boolean }) => (
<div data-testid="sidebar" data-collapsed={String(sidebarCollapsed)} />
default: ({ sidebarCollapsed, onToggleCollapsed }: { sidebarCollapsed: boolean; onToggleCollapsed: () => void }) => (
<div data-testid="sidebar" data-collapsed={String(sidebarCollapsed)}>
<button onClick={onToggleCollapsed}>Close navigation</button>
<a href="#settings">Settings</a>
</div>
),
}));
@ -89,6 +94,47 @@ describe("(dashboard) Layout", () => {
vi.mocked(usePathname).mockReturnValue("/ui/guardrails");
});
it("starts mobile navigation closed, opens a modal drawer and closes it after choosing a page", async () => {
render(
<AuthProvider>
<Layout>
<p>Gateway content</p>
</Layout>
</AuthProvider>,
);
pendingUiConfig.resolve();
const trigger = await screen.findByRole("button", { name: "Open navigation" });
expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument();
fireEvent.click(trigger);
const navigation = await screen.findByRole("dialog", { name: "Navigation" });
expect(within(navigation).getByRole("button", { name: "Close navigation" })).toBeInTheDocument();
fireEvent.click(within(navigation).getByRole("link", { name: "Settings" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument());
});
it("closes the mobile drawer when navigation changes outside the drawer", async () => {
const dashboard = () => (
<AuthProvider>
<Layout>
<p>Gateway content</p>
</Layout>
</AuthProvider>
);
const { rerender } = render(dashboard());
pendingUiConfig.resolve();
fireEvent.click(await screen.findByRole("button", { name: "Open navigation" }));
expect(await screen.findByRole("dialog", { name: "Navigation" })).toBeInTheDocument();
vi.mocked(usePathname).mockReturnValue("/ui/api-keys");
rerender(dashboard());
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument());
vi.mocked(usePathname).mockReturnValue("/ui/guardrails");
rerender(dashboard());
expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument();
});
it("collapses the sidebar on Logs for a full-screen view and expands it again after leaving", async () => {
const dashboard = () => (
<AuthProvider>

View file

@ -19,6 +19,10 @@ import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
import { getProxyBaseUrl } from "@/components/networking";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" });
@ -104,7 +108,16 @@ const FULL_BLEED_SEGMENTS = new Set(["logs"]);
function DashboardShell({ children }: { children: React.ReactNode }) {
const { accessToken } = useAuth();
const { mode } = usePluginMode();
const routeSegment = routeSegmentForPathname(usePathname());
const pathname = usePathname();
const routeSegment = routeSegmentForPathname(pathname);
const searchParams = useSearchParams();
const navigationKey = `${pathname}?${searchParams.toString()}`;
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [mobileNavigationKey, setMobileNavigationKey] = useState<string | null>(null);
if (mobileNavigationKey !== null && (isDesktop || mobileNavigationKey !== navigationKey)) {
setMobileNavigationKey(null);
}
const mobileNavigationOpen = !isDesktop && mobileNavigationKey === navigationKey;
const isFullBleed = FULL_BLEED_SEGMENTS.has(routeSegment);
// A manual toggle holds only for the route it was made on; full-bleed routes default to collapsed.
const [sidebarOverride, setSidebarOverride] = useState<{ segment: string; collapsed: boolean } | null>(null);
@ -138,21 +151,46 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
// sidebar owns its own scroll and the content column scrolls independently,
// so the page can't be dragged past the end of the nav.
return (
<div className="flex h-screen overflow-hidden bg-background">
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
<LiteAdminFrame>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
<Sheet open={mobileNavigationOpen} onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}>
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
<div className="hidden h-full md:flex">
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
</div>
</LiteAdminFrame>
</div>
<SheetContent
side="left"
showCloseButton={false}
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
onClickCapture={(event) => {
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
}}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
<LiteAdminFrame>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
navigationTrigger={
<SheetTrigger
render={
<Button variant="ghost" size="icon" className="size-11 md:hidden" aria-label="Open navigation" />
}
>
<Menu />
</SheetTrigger>
}
/>
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
</div>
</LiteAdminFrame>
</div>
</Sheet>
);
}

View file

@ -0,0 +1,109 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginModeProvider } from "@/contexts/PluginModeContext";
import { DashboardHeader } from "./DashboardHeader";
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" }));
beforeEach(() => {
localStorage.clear();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/get/ui_settings")) return Response.json({ values: {} });
if (url.endsWith("/litellm/.well-known/litellm-ui-config")) {
const config = {
server_root_path: "",
proxy_base_url: "",
is_control_plane: true,
workers: [
{ worker_id: "primary", name: "Primary worker", url: "http://primary.test" },
{ worker_id: "backup", name: "Backup worker", url: "http://backup.test" },
],
};
return Response.json(config);
}
if (url.endsWith("/api/plugins")) {
return Response.json([{ name: "metrics", display_name: "Metrics", url: "/metrics" }]);
}
if (url.endsWith("/public/litellm_blog_posts")) {
return Response.json({
posts: [
{
title: "Gateway update",
description: "Release notes",
date: "2026-01-01",
url: "https://example.com/update",
},
],
});
}
return Response.json({}, { status: 404 });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
async function openTools() {
const user = userEvent.setup();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<PluginModeProvider accessToken="test-session">
<DashboardHeader />
</PluginModeProvider>
</QueryClientProvider>,
);
await user.click(screen.getByRole("button", { name: "More options" }));
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
return { user, tools };
}
describe("mobile header nested controls", () => {
it.each(["mouse", "touch"] as const)(
"selects a portalled gateway item with %s without dismissing More",
async (pointer) => {
const { user, tools } = await openTools();
await user.click(within(tools).getByRole("button", { name: "AI Gateway" }));
const item = await screen.findByRole("menuitem", { name: "Metrics" });
if (pointer === "touch") {
await user.pointer([
{ keys: "[TouchA>]", target: item },
{ keys: "[/TouchA]", target: item },
]);
} else {
await user.click(item);
}
expect(within(tools).getByRole("button", { name: "Metrics" })).toBeVisible();
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
},
);
it("lets a nested blog link receive the click before closing its menu", async () => {
const { user, tools } = await openTools();
await user.click(within(tools).getByRole("button", { name: "Blog" }));
const link = await screen.findByRole("link", { name: /Gateway update/ });
expect(link).toHaveAttribute("href", "https://example.com/update");
await user.click(link);
expect(screen.queryByRole("menu", { name: "Blog" })).not.toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
});
it("allows filtering and selecting a worker from the nested combobox", async () => {
localStorage.setItem("litellm_selected_worker_id", "primary");
const { user, tools } = await openTools();
const worker = await within(tools).findByRole("combobox", { name: "Worker" });
await user.clear(worker);
await user.type(worker, "Backup");
await user.click(await screen.findByRole("option", { name: "Backup worker" }));
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
});
});

View file

@ -8,6 +8,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
plugins: [] as { name: string; display_name: string; url: string }[],
enableChatUI: false,
pathname: "/ui/logs",
isDesktop: false,
};
return {
state,
@ -19,6 +20,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: () => state.pathname }));
vi.mock("usehooks-ts", () => ({ useMediaQuery: () => state.isDesktop }));
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 }));
@ -34,6 +36,7 @@ describe("DashboardHeader breadcrumb", () => {
state.plugins = [];
state.enableChatUI = false;
state.pathname = "/ui/logs";
state.isDesktop = false;
});
it("titles the breadcrumb from the current route, not from a sidebar page id", () => {
@ -99,4 +102,27 @@ describe("DashboardHeader breadcrumb", () => {
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("keeps the gateway selector and tools available from the compact header menu", async () => {
render(<DashboardHeader />);
fireEvent.click(screen.getByRole("button", { name: "More options" }));
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument();
expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument();
expect(within(tools).getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
});
it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => {
const { rerender } = render(<DashboardHeader />);
fireEvent.click(screen.getByRole("button", { name: "More options" }));
expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument();
state.isDesktop = true;
rerender(<DashboardHeader />);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
state.isDesktop = false;
rerender(<DashboardHeader />);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
});
});

View file

@ -22,11 +22,19 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPro
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
import { usePathname } from "next/navigation";
import { useState, type ReactNode } from "react";
import { useMediaQuery } from "usehooks-ts";
import { Ellipsis } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
// 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() {
export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: ReactNode }) {
const { title } = getBreadcrumb(usePathname());
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
if (isDesktop && mobileToolsOpen) setMobileToolsOpen(false);
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const hideCommunityLinks = useDisableShowPrompts();
@ -40,20 +48,21 @@ export function DashboardHeader() {
};
return (
<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">
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4 max-md:gap-2 max-md:px-2">
{navigationTrigger}
<Breadcrumb className="min-w-0 max-md:flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="flex-none">
<BreadcrumbItem className="flex-none max-md:hidden">
<ViewSwitcher />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbSeparator className="max-md:hidden" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex flex-none items-center gap-1">
<div className="flex flex-none items-center gap-1 max-md:hidden">
{showWorkerSwitch && (
<>
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
@ -68,6 +77,26 @@ export function DashboardHeader() {
<ThemeToggle />
<NotificationsBell />
</div>
<div className="flex shrink-0 items-center gap-1 md:hidden">
<NotificationsBell />
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
<PopoverTrigger render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}>
<Ellipsis />
</PopoverTrigger>
<PopoverContent align="end" className="max-w-[calc(100vw-2rem)]">
<PopoverTitle>Gateway tools</PopoverTitle>
<ViewSwitcher />
{showWorkerSwitch && <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />}
<div className="flex flex-wrap items-center gap-2">
<LiteAdmin />
<ThemeToggle />
<DocsLink />
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
</div>
</PopoverContent>
</Popover>
</div>
</header>
);
}

View file

@ -139,7 +139,7 @@ SidebarMenuBadge.displayName = "SidebarMenuBadge";
const sidebarMenuButtonVariants = cva(
[
"group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline",
"group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline max-md:min-h-11",
"text-sidebar-foreground/70 outline-none transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",