refactor(ui): full-height sidebar shell with content-scoped top bar

Move the admin dashboard to a standard fixed-viewport shell. The sidebar is now full-height with its own scrolling nav (fixed logo header, pinned footer) and the top bar sits only over the content, so the page can no longer scroll past the end of the sidebar

The brand, version, collapse toggle, and account menu move into the sidebar; the AI Gateway/Chat switch, docs/blog/community links, notifications, and worker switcher stay in the top bar. The sidebar is rebuilt on a new shadcn ui/sidebar primitive that uses the existing design-system tokens instead of the antd Menu

This is a pure move-around of the sidebar, header, and content with no behavioral change intended. Chat keeps its own shell and navbar and is deliberately out of scope
This commit is contained in:
Yuneng Jiang 2026-07-10 09:30:33 -07:00
parent 2a12707372
commit 5a654c5c61
No known key found for this signature in database
9 changed files with 926 additions and 454 deletions

View file

@ -9,9 +9,15 @@ interface SidebarProviderProps {
setPage: (page: string) => void;
defaultSelectedKey: string;
sidebarCollapsed: boolean;
onToggleCollapsed?: () => void;
}
const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => {
const SidebarProvider = ({
setPage,
defaultSelectedKey,
sidebarCollapsed,
onToggleCollapsed,
}: SidebarProviderProps) => {
const { accessToken } = useAuthorized();
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
const [enableProjectsUI, setEnableProjectsUI] = useState<boolean>(false);
@ -72,6 +78,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side
setPage={setPage}
defaultSelectedKey={defaultSelectedKey}
collapsed={sidebarCollapsed}
onToggleCollapsed={onToggleCollapsed}
enabledPagesInternalUsers={enabledPagesInternalUsers}
enableProjectsUI={enableProjectsUI}
enableChatUI={enableChatUI}

View file

@ -0,0 +1,19 @@
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
/**
* Shared sign-out handler. Used by both the top navbar and the sidebar footer so
* the two entry points can never drift on which client state gets cleared.
*/
export function useLogout(accessToken: string | null): () => void {
const proxySettings = useProxySettings(accessToken);
return () => {
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
};
}

View file

@ -1,7 +1,7 @@
"use client";
import React, { Suspense, useState, useRef, useEffect } from "react";
import Navbar from "@/components/navbar";
import { DashboardHeader } from "@/components/DashboardHeader";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { useAuth } from "@/contexts/AuthContext";
@ -102,34 +102,36 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
const { mode } = usePluginMode();
const page = legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys";
const isGateway = mode === "ai-gateway";
const navigateToPage = (newPage: string) => {
const migratedRoute = MIGRATED_PAGES[newPage];
router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage));
};
// Standard app shell: the viewport is fixed height and never scrolls. The
// 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 flex-col min-h-screen">
<Navbar
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
/>
<DebugWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<div className="flex flex-1">
{mode !== "ai-gateway" ? (
<div className="flex-1 flex">
<AgentControlPlaneView />
</div>
<div className="flex h-screen overflow-hidden bg-background">
{isGateway && (
<SidebarProvider
setPage={navigateToPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
/>
)}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader page={page} />
<DebugWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
{isGateway ? (
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
) : (
<>
<div className="mt-2">
<SidebarProvider setPage={navigateToPage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
<main className="flex-1 min-w-0">{children}</main>
</>
<main className="flex min-w-0 flex-1 overflow-hidden">
<AgentControlPlaneView />
</main>
)}
</div>
</div>

View file

@ -0,0 +1,74 @@
"use client";
import { ChevronRight } from "lucide-react";
import { Separator } from "@/components/ui/separator";
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 WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
interface DashboardHeaderProps {
page: string;
}
// 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 { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const hideCommunityLinks = useDisableShowPrompts();
const handleWorkerSwitch = (workerId: string) => {
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`;
};
return (
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4">
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
{section && (
<>
<span className="whitespace-nowrap text-muted-foreground">{section}</span>
<ChevronRight className="size-4 flex-none text-muted-foreground" aria-hidden />
</>
)}
<span className="truncate font-medium text-foreground">{title}</span>
</nav>
<div className="flex flex-none items-center gap-1">
{showWorkerSwitch && (
<>
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
<Separator orientation="vertical" className="mx-1.5 h-5" />
</>
)}
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Docs
</a>
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
<Separator orientation="vertical" className="mx-1.5 h-5" />
<NotificationsBell />
<Separator orientation="vertical" className="mx-1.5 h-5" />
<ViewSwitcher />
</div>
</header>
);
}
export default DashboardHeader;

View file

@ -20,6 +20,8 @@ import {
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/cva.config";
import React, { useEffect, useState } from "react";
const { Text } = Typography;
@ -59,9 +61,14 @@ function initialsFromIdentity(email: string | null, userId: string | null): stri
interface UserDropdownProps {
onLogout: () => void;
// "navbar" (default): compact top-right trigger. "sidebar": full-width footer
// trigger whose menu opens upward, for the redesigned sidebar dock.
variant?: "navbar" | "sidebar";
// Sidebar rail mode: render the avatar only (no name/role).
collapsed?: boolean;
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar", collapsed = false }) => {
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
const disableShowPrompts = useDisableShowPrompts();
const disableUsageIndicator = useDisableUsageIndicator();
@ -219,6 +226,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
return (
<Dropdown
trigger={["click"]}
placement={variant === "sidebar" ? "topLeft" : "bottomRight"}
menu={{ items: userItems }}
popupRender={(menu) => (
<div className="rounded-lg bg-white shadow-lg" data-testid="user-dropdown-panel">
@ -230,24 +238,54 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
</div>
)}
>
<Button
type="text"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
aria-hidden
{variant === "sidebar" ? (
<button
type="button"
className={cn(
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
title={collapsed ? displayName : undefined}
>
{initials}
</span>
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
{displayName}
</span>
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
</Button>
<span
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
aria-hidden
>
{initials}
</span>
{!collapsed && (
<>
<span className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-[13px] font-medium text-sidebar-foreground">{displayName}</span>
{userRole && <span className="block truncate text-[11px] text-muted-foreground">{userRole}</span>}
</span>
<ChevronsUpDown size={16} strokeWidth={1.75} className="shrink-0 text-muted-foreground" aria-hidden />
</>
)}
</button>
) : (
<Button
type="text"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
aria-hidden
>
{initials}
</span>
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
{displayName}
</span>
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
</Button>
)}
</Dropdown>
);
};

View file

@ -0,0 +1,145 @@
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
import { cn } from "@/lib/cva.config";
import { useQuery } from "@tanstack/react-query";
import { Award, ChevronDown, Loader2 } from "lucide-react";
import { useState } from "react";
import { getRemainingUsers } from "./networking";
interface SidebarUsageCardProps {
accessToken: string | null;
collapsed: boolean;
onExpandRail: () => void;
}
interface Meter {
label: string;
used: number;
total: number;
}
const formatExpiration = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
return `${Math.floor(daysRemaining / 30)} months remaining`;
};
const meterBarClass = (pct: number): string => {
if (pct > 100) return "bg-destructive";
if (pct >= 90) return "bg-amber-500";
return "bg-sidebar-primary";
};
const Meter = ({ label, used, total }: Meter) => {
const pct = total > 0 ? (used / total) * 100 : 0;
return (
<div>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium tabular-nums">
<span className="text-foreground">{used.toLocaleString()}</span>
<span className="text-muted-foreground"> / {total.toLocaleString()}</span>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-[width] duration-300", meterBarClass(pct))}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
);
};
type RemainingUsage = NonNullable<Awaited<ReturnType<typeof getRemainingUsers>>>;
const remainingUsersQuery = (accessToken: string | null) => ({
queryKey: ["sidebarRemainingUsers", accessToken] as const,
queryFn: () => getRemainingUsers(accessToken as string),
enabled: Boolean(accessToken),
retry: false as const,
staleTime: 5 * 60 * 1000,
});
const buildMeters = (data: RemainingUsage | null): Meter[] => {
if (!data) return [];
return [
...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []),
...(data.total_teams != null ? [{ label: "Teams", used: data.total_teams_used, total: data.total_teams }] : []),
];
};
/**
* Bottom-dock "Enterprise usage" card for the sidebar. Backed only by data
* LiteLLM actually exposes: seat (user) and team allocations from the license,
* plus the license expiry. There is no plan-level spend or request cap, so the
* design's Spend / API-request meters are intentionally omitted.
*/
export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) {
const disableUsageIndicator = useDisableUsageIndicator();
const [open, setOpen] = useState(true);
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken));
const data = usageData ?? null;
const hasData = data !== null && (data.total_users !== null || data.total_teams !== null);
const noUsableData = !isLoading && !hasData;
if (disableUsageIndicator || !accessToken || noUsableData) {
return null;
}
if (collapsed) {
return (
<button
type="button"
onClick={onExpandRail}
title="Enterprise usage"
className="flex h-9 w-full items-center justify-center rounded-lg border border-sidebar-border bg-sidebar text-sidebar-primary transition-colors hover:bg-sidebar-accent"
>
<Award className="size-[18px]" strokeWidth={1.75} />
</button>
);
}
const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null;
const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan";
const meters = buildMeters(data);
return (
<div className="overflow-hidden rounded-xl border border-sidebar-border bg-sidebar">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-sidebar-accent"
>
<span className="flex size-[26px] flex-none items-center justify-center rounded-md bg-sidebar-primary/10 text-sidebar-primary">
<Award className="size-4" strokeWidth={1.75} />
</span>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13px] font-semibold text-foreground">Enterprise usage</span>
<span className="block truncate text-[11px] text-muted-foreground">{subtitle}</span>
</span>
<ChevronDown
className={cn("size-4 flex-none text-muted-foreground transition-transform", !open && "-rotate-90")}
/>
</button>
{open && (
<div className="flex flex-col gap-3 px-3 pt-0.5 pb-3">
{isLoading && meters.length === 0 ? (
<div className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading…
</div>
) : (
meters.map((m) => <Meter key={m.label} {...m} />)
)}
</div>
)}
</div>
);
}

View file

@ -1,7 +1,7 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import Sidebar from "./leftnav";
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
vi.mock("../utils/roles", () => {
return {
@ -56,6 +56,23 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => {
};
});
// The redesigned sidebar reads the custom logo from ThemeContext; the test tree
// has no ThemeProvider, so stub the hook.
vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => ({ logoUrl: null, faviconUrl: null, setLogoUrl: vi.fn(), setFaviconUrl: vi.fn() }),
}));
// Version tag + logout target come from network hooks; keep them inert in unit tests.
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: () => ({ data: undefined }),
}));
vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({
useLogout: () => vi.fn(),
}));
const collectNavKeys = (): string[] =>
menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)]));
describe("Sidebar (leftnav)", () => {
const defaultProps = {
setPage: vi.fn(),
@ -117,33 +134,11 @@ describe("Sidebar (leftnav)", () => {
});
});
it("has no duplicate keys among all menu items and their children", () => {
// Helper to recursively extract all keys from Ant Design Menu items
function getAllKeysFromMenu(wrapper: HTMLElement): string[] {
const allKeys: string[] = [];
// Ant Design renders key as data-menu-id or inside attributes, but for this case, we look for text as fallback.
// For a generic check, here we fetch ids from rendered list items, and also descend into submenus
const items = wrapper.querySelectorAll("[data-menu-id]");
items.forEach((item) => {
const dataMenuId = item.getAttribute("data-menu-id");
if (dataMenuId) {
allKeys.push(dataMenuId);
}
});
return allKeys;
}
const { container } = renderWithProviders(<Sidebar {...defaultProps} />);
const allRenderedKeys = getAllKeysFromMenu(container);
const keySet = new Set<string>();
const duplicates: string[] = [];
for (const key of allRenderedKeys) {
if (keySet.has(key)) {
duplicates.push(key);
}
keySet.add(key);
}
expect(duplicates).toHaveLength(0);
// React keys must be unique across the whole nav config, otherwise the
// active-item highlight and group expansion collide.
const keys = collectNavKeys();
const duplicates = keys.filter((key, i) => keys.indexOf(key) !== i);
expect(duplicates).toEqual([]);
});
describe("Admin Viewer parity", () => {
@ -231,4 +226,34 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Organizations")).toBeInTheDocument();
});
it("marks the selected page's nav item active", () => {
renderWithProviders(<Sidebar {...defaultProps} defaultSelectedKey="logs" />);
const logs = screen.getByText("Logs").closest("a");
expect(logs).toHaveAttribute("data-active", "true");
// A different item must not be active.
expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active");
});
it("hides labels but keeps items when collapsed to the rail", () => {
const { container } = renderWithProviders(<Sidebar {...defaultProps} collapsed />);
expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true");
// Items still render (icons), so navigation is reachable in rail mode.
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
});
});
describe("getBreadcrumb", () => {
it("resolves a top-level page to its section + title", () => {
expect(getBreadcrumb("api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" });
});
it("resolves a nested child page to its parent section", () => {
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
});
it("falls back to a prettified title with no section for unknown pages", () => {
expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
});
});

View file

@ -1,38 +1,68 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { getProxyBaseUrl } from "@/components/networking";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import {
ApiOutlined,
ApartmentOutlined,
AppstoreOutlined,
AuditOutlined,
BankOutlined,
BarChartOutlined,
BgColorsOutlined,
BlockOutlined,
BookOutlined,
CommentOutlined,
CreditCardOutlined,
DatabaseOutlined,
ExperimentOutlined,
ExportOutlined,
FileTextOutlined,
FolderOutlined,
KeyOutlined,
LineChartOutlined,
PlayCircleOutlined,
RobotOutlined,
SafetyOutlined,
SearchOutlined,
SettingOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { ConfigProvider, Layout, Menu } from "antd";
import { useMemo } from "react";
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarSeparator,
sidebarMenuButtonVariants,
} from "@/components/ui/sidebar";
import {
Activity,
BarChart3,
Bell,
Blocks,
Bot,
BookOpen,
Building2,
Boxes,
ChevronRight,
Code2,
Database,
ExternalLink,
FileText,
FlaskConical,
Folder,
HeartPulse,
KeyRound,
LayoutGrid,
MessageSquare,
Network,
Palette,
PanelLeftClose,
PanelLeftOpen,
PlayCircle,
Route,
ScrollText,
Search,
Server,
Settings as SettingsIcon,
Shield,
ShieldCheck,
Tags,
Terminal,
User,
Users,
Wallet,
Wrench,
Workflow,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { cn } from "@/lib/cva.config";
import {
all_admin_roles,
internalUserRoles,
@ -43,15 +73,17 @@ import {
} from "../utils/roles";
import NewBadge from "./common_components/NewBadge";
import type { Organization } from "./networking";
import UsageIndicator from "./UsageIndicator";
import SidebarUsageCard from "./SidebarUsageCard";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
const { Sider } = Layout;
// Define the props type
const ICON = { strokeWidth: 1.75 } as const;
interface SidebarProps {
setPage: (page: string) => void;
defaultSelectedKey: string;
collapsed?: boolean;
onToggleCollapsed?: () => void;
enabledPagesInternalUsers?: string[] | null;
enableProjectsUI?: boolean;
enableChatUI?: boolean;
@ -61,7 +93,6 @@ interface SidebarProps {
allowVectorStoresForTeamAdmins?: boolean;
}
// Menu item configuration
interface MenuItem {
key: string;
page: string;
@ -72,29 +103,25 @@ interface MenuItem {
external_url?: string;
}
// Group configuration
interface MenuGroup {
groupLabel: string;
items: MenuItem[];
roles?: string[];
}
// Menu groups organized by category - defined outside component for export
// Menu groups organized by category - defined outside component for export.
// Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the
// icons changed to lucide as part of the sidebar redesign.
const menuGroups: MenuGroup[] = [
{
groupLabel: "AI GATEWAY",
items: [
{
key: "api-keys",
page: "api-keys",
label: "Virtual Keys",
icon: <KeyOutlined />,
},
{ key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: <KeyRound {...ICON} /> },
{
key: "llm-playground",
page: "llm-playground",
label: "Playground",
icon: <PlayCircleOutlined />,
icon: <PlayCircle {...ICON} />,
roles: rolesWithWriteAccess,
},
{
@ -105,96 +132,51 @@ const menuGroups: MenuGroup[] = [
Chat <NewBadge />
</span>
),
icon: <CommentOutlined />,
icon: <MessageSquare {...ICON} />,
},
{
key: "models",
page: "models",
label: "Models + Endpoints",
icon: <BlockOutlined />,
// Admin Viewer can view models read-only (write actions are
// hidden inside the page); Playground above stays write-only.
icon: <Network {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{
key: "agentic",
page: "agentic",
label: "Agentic",
icon: <RobotOutlined />,
icon: <Bot {...ICON} />,
children: [
{
key: "agents",
page: "agents",
label: "Agents",
icon: <RobotOutlined />,
// Admin Viewer can view agents read-only (write actions are
// hidden inside the page); Playground above stays write-only.
icon: <Bot {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{
key: "workflows",
page: "workflows",
label: "Workflow Runs",
icon: <ApartmentOutlined />,
},
{
key: "memory",
page: "memory",
label: "Memory",
icon: <BookOutlined />,
},
{ key: "workflows", page: "workflows", label: "Workflow Runs", icon: <Workflow {...ICON} /> },
{ key: "memory", page: "memory", label: "Memory", icon: <Database {...ICON} /> },
],
},
{
key: "mcp-servers",
page: "mcp-servers",
label: "MCP Servers",
icon: <ToolOutlined />,
},
{
key: "skills",
page: "skills",
label: "Skills",
icon: <ApiOutlined />,
roles: all_admin_roles,
},
{
key: "guardrails",
page: "guardrails",
label: "Guardrails",
icon: <SafetyOutlined />,
},
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
{ key: "skills", page: "skills", label: "Skills", icon: <Blocks {...ICON} />, roles: all_admin_roles },
{ key: "guardrails", page: "guardrails", label: "Guardrails", icon: <Shield {...ICON} /> },
{
key: "policies",
page: "policies",
label: <span className="flex items-center gap-4">Policies</span>,
icon: <AuditOutlined />,
label: "Policies",
icon: <ScrollText {...ICON} />,
roles: all_admin_roles,
},
{
key: "tools",
page: "tools",
label: "Tools",
icon: <ToolOutlined />,
icon: <Wrench {...ICON} />,
children: [
{
key: "search-tools",
page: "search-tools",
label: "Search Tools",
icon: <SearchOutlined />,
},
{
key: "vector-stores",
page: "vector-stores",
label: "Vector Stores",
icon: <DatabaseOutlined />,
},
{
key: "tool-policies",
page: "tool-policies",
label: "Tool Policies",
icon: <SafetyOutlined />,
},
{ key: "search-tools", page: "search-tools", label: "Search Tools", icon: <Search {...ICON} /> },
{ key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: <Database {...ICON} /> },
{ key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: <ShieldCheck {...ICON} /> },
],
},
],
@ -205,21 +187,16 @@ const menuGroups: MenuGroup[] = [
{
key: "new_usage",
page: "new_usage",
icon: <BarChartOutlined />,
icon: <BarChart3 {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
label: "Usage",
},
{
key: "logs",
page: "logs",
label: "Logs",
icon: <LineChartOutlined />,
},
{ key: "logs", page: "logs", label: "Logs", icon: <Activity {...ICON} /> },
{
key: "guardrails-monitor",
page: "guardrails-monitor",
label: "Guardrails Monitor",
icon: <SafetyOutlined />,
icon: <HeartPulse {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
},
],
@ -227,12 +204,7 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "ACCESS CONTROL",
items: [
{
key: "teams",
page: "teams",
label: "Teams",
icon: <TeamOutlined />,
},
{ key: "teams", page: "teams", label: "Teams", icon: <Users {...ICON} /> },
{
key: "projects",
page: "projects",
@ -241,102 +213,62 @@ const menuGroups: MenuGroup[] = [
Projects <NewBadge />
</span>
),
icon: <FolderOutlined />,
roles: all_admin_roles,
},
{
key: "users",
page: "users",
label: "Internal Users",
icon: <UserOutlined />,
icon: <Folder {...ICON} />,
roles: all_admin_roles,
},
{ key: "users", page: "users", label: "Internal Users", icon: <User {...ICON} />, roles: all_admin_roles },
{
key: "organizations",
page: "organizations",
label: "Organizations",
icon: <BankOutlined />,
icon: <Building2 {...ICON} />,
roles: all_admin_roles,
},
{
key: "access-groups",
page: "access-groups",
label: "Access Groups",
icon: <BlockOutlined />,
roles: all_admin_roles,
},
{
key: "budgets",
page: "budgets",
label: "Budgets",
icon: <CreditCardOutlined />,
icon: <Boxes {...ICON} />,
roles: all_admin_roles,
},
{ key: "budgets", page: "budgets", label: "Budgets", icon: <Wallet {...ICON} />, roles: all_admin_roles },
],
},
{
groupLabel: "DEVELOPER TOOLS",
items: [
{
key: "api_ref",
page: "api_ref",
label: "API Reference",
icon: <ApiOutlined />,
},
{
key: "model-hub-table",
page: "model-hub-table",
label: "AI Hub",
icon: <AppstoreOutlined />,
},
{ key: "api_ref", page: "api_ref", label: "API Reference", icon: <Code2 {...ICON} /> },
{ key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: <LayoutGrid {...ICON} /> },
{
key: "learning-resources",
page: "learning-resources",
label: "Learning Resources",
icon: <BookOutlined />,
icon: <BookOpen {...ICON} />,
external_url: "https://models.litellm.ai/cookbook",
},
{
key: "experimental",
page: "experimental",
label: "Experimental",
icon: <ExperimentOutlined />,
icon: <FlaskConical {...ICON} />,
children: [
{
key: "caching",
page: "caching",
label: "Caching",
icon: <DatabaseOutlined />,
roles: all_admin_roles,
},
{
key: "prompts",
page: "prompts",
label: "Prompts",
icon: <FileTextOutlined />,
roles: all_admin_roles,
},
{ key: "caching", page: "caching", label: "Caching", icon: <Database {...ICON} />, roles: all_admin_roles },
{ key: "prompts", page: "prompts", label: "Prompts", icon: <FileText {...ICON} />, roles: all_admin_roles },
{
key: "transform-request",
page: "transform-request",
label: "API Playground",
icon: <ApiOutlined />,
icon: <Terminal {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{
key: "tag-management",
page: "tag-management",
label: "Tag Management",
icon: <TagsOutlined />,
icon: <Tags {...ICON} />,
roles: all_admin_roles,
},
{
key: "4",
page: "usage",
label: "Old Usage",
icon: <BarChartOutlined />,
},
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChart3 {...ICON} /> },
],
},
],
@ -353,21 +285,21 @@ const menuGroups: MenuGroup[] = [
Settings <NewBadge />
</span>
),
icon: <SettingOutlined />,
icon: <SettingsIcon {...ICON} />,
roles: all_admin_roles,
children: [
{
key: "router-settings",
page: "router-settings",
label: "Router Settings",
icon: <SettingOutlined />,
icon: <Route {...ICON} />,
roles: all_admin_roles,
},
{
key: "logging-and-alerts",
page: "logging-and-alerts",
label: "Logging & Alerts",
icon: <SettingOutlined />,
icon: <Bell {...ICON} />,
roles: all_admin_roles,
},
{
@ -381,33 +313,78 @@ const menuGroups: MenuGroup[] = [
</NewBadge>
</span>
),
icon: <SettingOutlined />,
icon: <SettingsIcon {...ICON} />,
roles: all_admin_roles,
},
{
key: "cost-tracking",
page: "cost-tracking",
label: "Cost Tracking",
icon: <BarChartOutlined />,
roles: all_admin_roles,
},
{
key: "ui-theme",
page: "ui-theme",
label: "UI Theme",
icon: <BgColorsOutlined />,
icon: <BarChart3 {...ICON} />,
roles: all_admin_roles,
},
{ key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: <Palette {...ICON} />, roles: all_admin_roles },
],
},
],
},
];
const Sidebar: React.FC<SidebarProps> = ({
const findParentKey = (page: string): string | null => {
for (const group of menuGroups) {
for (const item of group.items) {
if (item.children?.some((c) => c.page === page || c.key === page)) return item.key;
}
}
return null;
};
const findMenuItemKey = (page: string): string => {
for (const group of menuGroups) {
for (const item of group.items) {
if (item.page === page) return item.key;
const child = item.children?.find((c) => c.page === page);
if (child) return child.key;
}
}
return "api-keys";
};
const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : item.key);
const SECTION_DISPLAY: Record<string, string> = {
"AI GATEWAY": "AI Gateway",
OBSERVABILITY: "Observability",
"ACCESS CONTROL": "Access Control",
"DEVELOPER TOOLS": "Developer Tools",
SETTINGS: "Settings",
};
const prettify = (key: string): string =>
key
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
export const getBreadcrumb = (page: string): { section: string | null; title: string } => {
for (const group of menuGroups) {
for (const item of group.items) {
const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel;
if (item.page === page)
return { section, title: typeof item.label === "string" ? item.label : prettify(item.key) };
const child = item.children?.find((c) => c.page === page);
if (child) return { section, title: typeof child.label === "string" ? child.label : prettify(child.key) };
}
}
return { section: null, title: prettify(page) };
};
const Sidebar_: React.FC<SidebarProps> = ({
setPage,
defaultSelectedKey,
collapsed = false,
onToggleCollapsed,
enabledPagesInternalUsers,
enableProjectsUI,
enableChatUI,
@ -419,8 +396,31 @@ const Sidebar: React.FC<SidebarProps> = ({
const { userId, accessToken, userRole } = useAuthorized();
const { data: organizations } = useOrganizations();
const { data: teams } = useTeams();
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const logout = useLogout(accessToken);
const baseUrl = getProxyBaseUrl();
const version = healthData?.litellm_version;
const selectedKey = findMenuItemKey(defaultSelectedKey);
const [openGroups, setOpenGroups] = useState<Set<string>>(() => {
const parent = findParentKey(defaultSelectedKey);
return new Set(parent ? [parent] : []);
});
// Keep the active page's parent group expanded as the user navigates, using the
// "adjust state during render" pattern rather than an effect (avoids a
// setState-in-effect render cascade).
const [prevSelectedKey, setPrevSelectedKey] = useState(defaultSelectedKey);
if (defaultSelectedKey !== prevSelectedKey) {
setPrevSelectedKey(defaultSelectedKey);
const parent = findParentKey(defaultSelectedKey);
if (parent && !openGroups.has(parent)) {
setOpenGroups((prev) => new Set(prev).add(parent));
}
}
// Check if user is an org_admin
const isOrgAdmin = useMemo(() => {
if (!userId || !organizations) return false;
return organizations.some((org: Organization) =>
@ -428,83 +428,21 @@ const Sidebar: React.FC<SidebarProps> = ({
);
}, [userId, organizations]);
// Check if user is a team admin for any team
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
// The parent (legacy root page or dashboard layout) owns navigation for both
// migrated and legacy pages; the sidebar only reports the selected page.
const navigateToPage = (page: string) => setPage(page);
// Wrap label in <a> so every nav item supports right-click → "Open in new tab"
// and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks.
const renderNavLink = (label: React.ReactNode, page: string, externalUrl?: string): React.ReactNode => {
if (externalUrl) {
return (
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{ color: "inherit", textDecoration: "none" }}
>
{label} <ExportOutlined style={{ fontSize: 10, marginLeft: 4 }} />
</a>
);
}
const migratedRoute = MIGRATED_PAGES[page];
const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page);
return (
<a
href={href}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
e.stopPropagation();
return;
}
e.preventDefault();
}}
style={{ color: "inherit", textDecoration: "none" }}
>
{label}
</a>
);
};
// Filter items based on user role and enabled pages for internal users
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
const isAdmin = isAdminRole(userRole);
// Debug logging
if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
}
return items
.map((item) => ({
...item,
children: item.children ? filterItemsByRole(item.children) : undefined,
}))
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
.filter((item) => {
// Special handling for organizations and users menu items - allow org_admins
if (item.key === "organizations" || item.key === "users") {
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
if (!hasRoleAccess) return false;
// Check enabled pages for internal users (non-admins)
if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
const isIncluded = enabledPagesInternalUsers.includes(item.page);
return isIncluded;
}
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
// Hide Projects page if enableProjectsUI is not enabled
if (item.key === "projects" && !enableProjectsUI) return false;
// Hide Chat page if enableChatUI is not enabled
if (item.key === "chat" && !enableChatUI) return false;
// Hide agents and vector-stores pages for non-admin users when disabled,
// unless allow_*_for_team_admins is on and the user is a team admin.
if (
!isAdmin &&
item.key === "agents" &&
@ -519,160 +457,181 @@ const Sidebar: React.FC<SidebarProps> = ({
!(allowVectorStoresForTeamAdmins && isTeamAdmin)
)
return false;
// Existing role check
if (item.roles && !item.roles.includes(userRole)) return false;
// Check enabled pages for internal users (non-admins)
if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
// If item has children, check if any children are visible
if (!isAdmin && enabledPagesInternalUsers != null) {
if (item.children && item.children.length > 0) {
const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page));
if (hasVisibleChildren) {
return true;
}
if (hasVisibleChildren) return true;
}
const isIncluded = enabledPagesInternalUsers.includes(item.page);
return isIncluded;
return enabledPagesInternalUsers.includes(item.page);
}
return true;
});
};
// Build menu items with groups
const buildMenuItems = (): MenuProps["items"] => {
const items: MenuProps["items"] = [];
const visibleGroups = menuGroups
.filter((group) => !group.roles || group.roles.includes(userRole))
.map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) }))
.filter((group) => group.items.length > 0);
menuGroups.forEach((group) => {
// Check if group has role restriction
if (group.roles && !group.roles.includes(userRole)) {
return;
}
const filteredItems = filterItemsByRole(group.items);
if (filteredItems.length === 0) return;
// Add group with items
items.push({
type: "group",
label: collapsed ? null : (
<span
style={{
fontSize: "10px",
fontWeight: 600,
color: "#6b7280",
letterSpacing: "0.05em",
padding: "12px 0 4px 12px",
display: "block",
marginBottom: "2px",
}}
>
{group.groupLabel}
</span>
),
children: filteredItems.map((item) => ({
key: item.key,
icon: item.icon,
label: renderNavLink(item.label, item.page, item.external_url),
children: item.children?.map((child) => ({
key: child.key,
icon: child.icon,
label: renderNavLink(child.label, child.page, child.external_url),
onClick: () => {
if (child.external_url) {
window.open(child.external_url, "_blank");
} else {
navigateToPage(child.page);
}
},
})),
onClick: !item.children
? () => {
if (item.external_url) {
window.open(item.external_url, "_blank");
} else {
navigateToPage(item.page);
}
}
: undefined,
})),
});
});
return items;
};
// Find selected menu key
const findMenuItemKey = (page: string): string => {
for (const group of menuGroups) {
for (const item of group.items) {
if (item.page === page) return item.key;
if (item.children) {
const child = item.children.find((c) => c.page === page);
if (child) return child.key;
}
}
const toggleGroup = (key: string) => {
if (collapsed) {
onToggleCollapsed?.();
setOpenGroups((prev) => new Set(prev).add(key));
return;
}
return "api-keys";
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedMenuKey = findMenuItemKey(defaultSelectedKey);
const handleLeafClick = (e: React.MouseEvent, item: MenuItem) => {
if (item.external_url) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
setPage(item.page);
};
const renderLeaf = (item: MenuItem, isChild: boolean) => {
const active = selectedKey === item.key;
const size = isChild ? "sub" : "default";
const label = <span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{item.label}</span>;
if (item.external_url) {
return (
<a
key={item.key}
href={item.external_url}
target="_blank"
rel="noopener noreferrer"
title={collapsed ? labelText(item) : undefined}
data-active={active || undefined}
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
>
{item.icon}
{label}
<ExternalLink className="size-3.5 shrink-0 opacity-70 group-data-[collapsed=true]/sidebar:hidden" />
</a>
);
}
const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page);
return (
<a
key={item.key}
href={href}
onClick={(e) => handleLeafClick(e, item)}
title={collapsed ? labelText(item) : undefined}
data-active={active || undefined}
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
>
{item.icon}
{label}
</a>
);
};
const renderItem = (item: MenuItem) => {
const isGroup = !!item.children && item.children.length > 0;
if (!isGroup) {
return <SidebarMenuItem key={item.key}>{renderLeaf(item, false)}</SidebarMenuItem>;
}
const active = selectedKey === item.key;
const open = openGroups.has(item.key);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
isActive={active}
onClick={() => toggleGroup(item.key)}
title={collapsed ? labelText(item) : undefined}
>
{item.icon}
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{item.label}</span>
<ChevronRight
className={cn(
"size-4 shrink-0 transition-transform group-data-[collapsed=true]/sidebar:hidden",
open && "rotate-90",
)}
/>
</SidebarMenuButton>
{open && (
<SidebarMenuSub>
{item.children!.map((child) => (
<SidebarMenuItem key={child.key}>{renderLeaf(child, true)}</SidebarMenuItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
};
const logoSrc = logoUrl || `${baseUrl}/get_image`;
return (
<Layout>
<Sider
theme="light"
width={220}
collapsed={collapsed}
collapsedWidth={80}
collapsible
trigger={null}
style={{
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
}}
>
<ConfigProvider
theme={{
components: {
Menu: {
iconSize: 15,
fontSize: 13,
itemMarginInline: 4,
itemPaddingInline: 8,
itemHeight: 30,
itemBorderRadius: 6,
subMenuItemBorderRadius: 6,
groupTitleFontSize: 10,
groupTitleLineHeight: 1.5,
},
},
}}
>
<Menu
mode="inline"
selectedKeys={[selectedMenuKey]}
defaultOpenKeys={[]}
inlineCollapsed={collapsed}
className="custom-sidebar-menu"
style={{
borderRight: 0,
backgroundColor: "transparent",
fontSize: "13px",
paddingTop: "4px",
}}
items={buildMenuItems()}
<Sidebar collapsed={collapsed}>
<SidebarHeader>
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
<div className="flex min-w-0 items-center gap-2">
<Link href={baseUrl || "/"} className="flex min-w-0 items-center" aria-label="LiteLLM home">
<img
src={logoSrc}
alt="LiteLLM"
className="h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7"
/>
</Link>
{version && (
<a
href="https://docs.litellm.ai/release_notes"
target="_blank"
rel="noopener noreferrer"
className="rounded border border-border px-1.5 py-px font-mono text-[10px] font-medium text-muted-foreground transition-colors hover:text-foreground group-data-[collapsed=true]/sidebar:hidden"
>
v{version}
</a>
)}
</div>
{onToggleCollapsed && (
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="flex-none text-muted-foreground"
>
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</Button>
)}
</div>
</SidebarHeader>
<SidebarContent>
{visibleGroups.map((group, gi) => (
<SidebarGroup key={group.groupLabel}>
{gi > 0 && <SidebarSeparator className="hidden group-data-[collapsed=true]/sidebar:block" />}
<SidebarGroupLabel>{group.groupLabel}</SidebarGroupLabel>
<SidebarMenu>{group.items.map((item) => renderItem(item))}</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
{isAdminRole(userRole) && (
<SidebarUsageCard
accessToken={accessToken}
collapsed={collapsed}
onExpandRail={() => onToggleCollapsed?.()}
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
</Sider>
</Layout>
)}
<UserDropdown onLogout={logout} variant="sidebar" collapsed={collapsed} />
</SidebarFooter>
</Sidebar>
);
};
export default Sidebar;
export default Sidebar_;
// Also export menuGroups for advanced use cases
export { menuGroups };

View file

@ -0,0 +1,203 @@
"use client";
import * as React from "react";
import { type VariantProps } from "cva";
import { cn, cva } from "@/lib/cva.config";
type SidebarContextValue = { collapsed: boolean };
const SidebarContext = React.createContext<SidebarContextValue>({ collapsed: false });
export function useSidebar(): SidebarContextValue {
return React.useContext(SidebarContext);
}
const Sidebar = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<"aside"> & { collapsed?: boolean }>(
({ className, collapsed = false, children, ...props }, ref) => (
<SidebarContext.Provider value={{ collapsed }}>
<aside
ref={ref}
data-slot="sidebar"
data-collapsed={collapsed}
className={cn(
"group/sidebar flex h-full flex-none flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-in-out",
collapsed ? "w-[72px]" : "w-[280px]",
className,
)}
{...props}
>
{children}
</aside>
</SidebarContext.Provider>
),
);
Sidebar.displayName = "Sidebar";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="sidebar-header"
className={cn("flex flex-none flex-col gap-2 p-3", className)}
{...props}
/>
),
);
SidebarHeader.displayName = "SidebarHeader";
const SidebarContent = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<"nav">>(
({ className, ...props }, ref) => (
<nav
ref={ref}
data-slot="sidebar-content"
className={cn("flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-3 pb-3", className)}
{...props}
/>
),
);
SidebarContent.displayName = "SidebarContent";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="sidebar-footer"
className={cn("flex flex-none flex-col gap-2.5 border-t border-sidebar-border p-3", className)}
{...props}
/>
),
);
SidebarFooter.displayName = "SidebarFooter";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-slot="sidebar-group" className={cn("flex flex-col gap-0.5 py-1", className)} {...props} />
),
);
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="sidebar-group-label"
className={cn(
"px-2 pt-3 pb-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground uppercase group-data-[collapsed=true]/sidebar:hidden",
className,
)}
{...props}
/>
),
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentPropsWithoutRef<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} data-slot="sidebar-menu" className={cn("flex w-full flex-col gap-0.5", className)} {...props} />
),
);
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} data-slot="sidebar-menu-item" className={cn("relative", className)} {...props} />
),
);
SidebarMenuItem.displayName = "SidebarMenuItem";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentPropsWithoutRef<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-slot="sidebar-menu-sub"
className={cn(
"mx-3.5 my-0.5 flex min-w-0 flex-col gap-0.5 border-l border-sidebar-border py-0.5 pl-3 group-data-[collapsed=true]/sidebar:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuBadge = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
data-slot="sidebar-menu-badge"
className={cn(
"ml-auto flex-none rounded-full bg-sidebar-primary/10 px-1.5 py-px text-[10px] font-semibold text-sidebar-primary tabular-nums group-data-[collapsed=true]/sidebar:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const sidebarMenuButtonVariants = cva({
base: [
"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",
"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",
"disabled:pointer-events-none disabled:opacity-50",
"[&>svg]:size-[18px] [&>svg]:shrink-0",
"group-data-[collapsed=true]/sidebar:mx-auto group-data-[collapsed=true]/sidebar:size-9 group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:gap-0 group-data-[collapsed=true]/sidebar:px-0",
].join(" "),
variants: {
isActive: {
true: "bg-sidebar-accent text-sidebar-accent-foreground before:absolute before:inset-y-1.5 before:left-0 before:w-[3px] before:rounded-r-full before:bg-sidebar-primary group-data-[collapsed=true]/sidebar:before:hidden",
false: "",
},
size: {
default: "h-[34px]",
sub: "h-[34px]",
},
},
defaultVariants: { isActive: false, size: "default" },
});
type SidebarMenuButtonProps = React.ComponentPropsWithoutRef<"button"> & VariantProps<typeof sidebarMenuButtonVariants>;
const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
({ className, isActive, size, ...props }, ref) => (
<button
ref={ref}
type="button"
data-slot="sidebar-menu-button"
data-active={isActive || undefined}
className={cn(sidebarMenuButtonVariants({ isActive, size, className }))}
{...props}
/>
),
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="sidebar-separator"
className={cn("mx-2 my-2 h-px bg-sidebar-border", className)}
{...props}
/>
),
);
SidebarSeparator.displayName = "SidebarSeparator";
export {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuBadge,
SidebarSeparator,
sidebarMenuButtonVariants,
};