From 664241188f0e9ddea0ca8d27a898fbddeb7fb9cf Mon Sep 17 00:00:00 2001 From: moe-berri Date: Wed, 4 Nov 2026 16:56:06 +0800 Subject: [PATCH] feat(ui): redesign the dashboard top bar and give Aria its own page (#50009) * feat(ui): rebuild dashboard navigation and Aria workspace * refactor(ui): make Aria a single workspace and clarify dashboard controls * fix(ui): preserve link semantics for the Aria shortcut * style(ui): use Sparkle for Aria * style(ui): simplify workspace menu rows * fix(ui): keep worker controls compact on narrow headers * style(ui): remove workspace selector dark fill --- .../src/app/(dashboard)/aria/page.tsx | 5 + .../_components/HomePage.integration.test.tsx | 8 +- .../(dashboard)/home/_components/HomePage.tsx | 3 - .../src/app/(dashboard)/layout.tsx | 57 +- .../components/UsagePageView.test.tsx | 15 +- .../_components/components/UsagePageView.tsx | 3 +- .../CommandPalette/CommandPaletteTrigger.tsx | 14 +- .../DashboardHeader.integration.test.tsx | 82 +-- .../src/components/DashboardHeader.test.tsx | 87 +-- .../src/components/DashboardHeader.tsx | 115 +--- .../DeploymentHealth.integration.test.tsx | 39 +- .../DeploymentHealth/DeploymentHealth.tsx | 21 + .../components/Navbar/DashboardResources.tsx | 115 ++++ .../NotificationsBell/NotificationsBell.tsx | 68 +- .../Navbar/ViewSwitcher.integration.test.tsx | 4 +- .../src/components/Navbar/ViewSwitcher.tsx | 83 +-- .../WorkerDropdown/WorkerDropdown.test.tsx | 18 + .../Navbar/WorkerDropdown/WorkerDropdown.tsx | 28 +- .../src/components/leftnav.test.tsx | 14 + .../src/components/leftnav.tsx | 9 +- .../src/components/liteadmin/AriaAnswer.tsx | 12 +- .../components/liteadmin/AriaAvatar.test.tsx | 8 +- .../src/components/liteadmin/AriaAvatar.tsx | 15 +- .../src/components/liteadmin/AriaStart.tsx | 54 ++ .../liteadmin/LiteAdmin.integration.test.tsx | 625 ++++-------------- .../src/components/liteadmin/LiteAdmin.tsx | 360 ++++------ .../liteadmin/LiteAdminConversation.tsx | 59 +- .../liteadmin/LiteAdminHomeComposer.tsx | 100 --- .../src/components/liteadmin/agent.test.ts | 1 + .../src/components/liteadmin/agent.ts | 4 +- .../src/components/liteadmin/useLiteAdmin.ts | 1 + .../src/components/navbar.tsx | 4 +- .../src/components/ui/command.tsx | 7 +- 33 files changed, 776 insertions(+), 1262 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx delete mode 100644 ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx new file mode 100644 index 00000000000..45127adc65a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/aria/page.tsx @@ -0,0 +1,5 @@ +import { LiteAdminPage } from "@/components/liteadmin/LiteAdmin"; + +export default function AriaPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx index 1891a580088..c0fab36b5e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx @@ -7,6 +7,7 @@ import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActiv import { all_admin_roles } from "@/utils/roles"; import { LiteAdminFrame } from "@/components/liteadmin/LiteAdmin"; import HomePage from "./HomePage"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); const auth = vi.hoisted(() => ({ accessToken: "sk-test", @@ -144,7 +145,7 @@ describe("HomePage", () => { expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument(); }); - it("should offer Aria Agent on Home inside the dashboard frame", async () => { + it("keeps Home focused on usage without an embedded Aria composer", async () => { render( @@ -152,9 +153,8 @@ describe("HomePage", () => { , ); - const entry = screen.getByRole("region", { name: "Ask Aria Agent" }); - expect(await within(entry).findByPlaceholderText("What would you like to do?")).toBeInTheDocument(); - expect(within(entry).getByRole("button", { name: "Send message" })).toBeDisabled(); + expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument(); expect(await screen.findByText("$501.74")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx index b0c63bb5dc0..1faa9c1d802 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx @@ -8,7 +8,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Page } from "@/components/shared/Page"; import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader"; import { uiHref } from "@/utils/uiHref"; -import { LiteAdminHome } from "@/components/liteadmin/LiteAdmin"; import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart"; import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives"; import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip"; @@ -41,8 +40,6 @@ export default function HomePage() { )} - -
{usage.failed ? (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 92c8806df07..9a94e225932 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -17,6 +17,7 @@ import { createDeploymentHealthHandle, DeploymentHealthBanner, DeploymentHealthIcon, + DeploymentHealthLink, } from "@/components/DeploymentHealth/DeploymentHealth"; import { DeploymentHealthPanel } from "@/components/DeploymentHealth/DeploymentHealthPanel"; import { UserBanner } from "@/components/UserBanner"; @@ -151,6 +152,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { accessToken={accessToken} isPublicPage={false} deploymentHealth={} + deploymentHealthLink={} /> @@ -166,28 +168,28 @@ 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 ( -
-
- -
- setMobileNavigationKey(open ? navigationKey : null)} - > - { - if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null); - }} + +
+
+ +
+ setMobileNavigationKey(open ? navigationKey : null)} > - Navigation - setMobileNavigationKey(null)} /> - - - + { + if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null); + }} + > + Navigation + setMobileNavigationKey(null)} /> + +
} deploymentHealth={} + deploymentHealthLink={} /> -
+
{children}
-
-
+
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index f7ffd6d52cf..18ebee16745 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -248,8 +248,7 @@ vi.mock("@/components/EntityUsageExport", () => ({ })); vi.mock("@/components/liteadmin/LiteAdmin", () => ({ - AriaUsageTrigger: () => , - AriaUsageComposer: () =>
Shared Aria composer
, + AriaUsageTrigger: () => Ask Aria, })); vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({ @@ -1451,19 +1450,19 @@ describe("UsagePage", () => { }); describe("Aria Agent", () => { - it("should render the shared Aria prompt and entry point in global view", async () => { + it("offers a link to Aria without a second composer in global view", async () => { renderWithProviders(); await waitFor(() => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - expect(screen.getByRole("button", { name: "Open Aria Agent" })).toBeInTheDocument(); - expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Ask Aria" })).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument(); expect(screen.queryByText("Ask AI")).not.toBeInTheDocument(); }); - it("should keep the shared Aria prompt available in entity usage views", async () => { + it("does not embed a second Aria composer in entity usage views", async () => { renderWithProviders(); await waitFor(() => { @@ -1475,8 +1474,8 @@ describe("UsagePage", () => { }); expect(await screen.findByText("Entity Usage")).toBeInTheDocument(); - expect(screen.getAllByRole("region", { name: "Ask Aria Agent" })).toHaveLength(1); - expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible(); + expect(screen.queryByPlaceholderText(/Ask Aria/)).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Ask Aria Agent" })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 7c891754195..78159778d87 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -71,7 +71,7 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations"; -import { AriaUsageComposer, AriaUsageTrigger } from "@/components/liteadmin/LiteAdmin"; +import { AriaUsageTrigger } from "@/components/liteadmin/LiteAdmin"; import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; import UserRecordLink from "./UserRecordLink"; import { @@ -489,7 +489,6 @@ const UsagePage: React.FC = ({ teams, organizations, teamsLoadin
- {aggregatedFailed && ( )} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx index c89652bb1c5..880f2b56b46 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/cva.config"; import { Search } from "lucide-react"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -9,7 +10,7 @@ import { paletteScopeForRoute } from "./utils"; import { useCommandPalette } from "./CommandPaletteProvider"; import { useShortcutLabel } from "./useShortcutLabel"; -export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) { +export function CommandPaletteTrigger({ mobile = false, className }: { mobile?: boolean; className?: string }) { const pathname = usePathname(); const { open, setOpen } = useCommandPalette(); const shortcut = useShortcutLabel(); @@ -21,7 +22,7 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) ); } diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx index d1a6e9d1cb4..993277dc972 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -6,7 +6,11 @@ import { PluginModeProvider } from "@/contexts/PluginModeContext"; import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; import { DashboardHeader } from "./DashboardHeader"; -vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" })); +vi.mock("next/navigation", () => ({ + usePathname: () => "/ui/logs", + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); beforeEach(() => { localStorage.clear(); @@ -52,7 +56,7 @@ afterEach(() => { localStorage.clear(); }); -async function openTools() { +function renderHeader() { const user = userEvent.setup(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( @@ -64,49 +68,53 @@ async function openTools() { , ); - await user.click(screen.getByRole("button", { name: "More options" })); - const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); - return { user, tools }; + return { user }; } -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: "Switch workspace, current: 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: "Switch workspace, current: 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(); +describe("dashboard header controls", () => { + it("opens search from both header controls", async () => { + const { user } = renderHeader(); + await user.click(screen.getByRole("button", { name: "Search or jump to…" })); + expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible(); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Search", exact: true })); + expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible(); }); - it("allows filtering and selecting a worker from the nested combobox", async () => { + it("offers docs, community and latest posts under the bell", async () => { + const { user } = renderHeader(); + await user.click(screen.getByRole("button", { name: /Notifications/ })); + const tools = await screen.findByRole("dialog", { name: "What's new" }); + expect(await within(tools).findByRole("link", { name: /Gateway update/ })).toHaveAttribute( + "href", + "https://example.com/update", + ); + expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/", + ); + expect(within(tools).getByRole("link", { name: "Discord", exact: true })).toBeVisible(); + }); + + it("respects the saved blog and community visibility settings", async () => { + localStorage.setItem("disableBlogPosts", "true"); + localStorage.setItem("disableShowPrompts", "true"); + const { user } = renderHeader(); + expect(screen.queryByRole("link", { name: "Join Slack" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Notifications/ })); + const tools = await screen.findByRole("dialog", { name: "What's new" }); + expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toBeVisible(); + expect(within(tools).queryByRole("link", { name: "Blog", exact: true })).not.toBeInTheDocument(); + expect(within(tools).queryByRole("link", { name: "Discord", exact: true })).not.toBeInTheDocument(); + }); + + it("allows filtering and selecting a worker", async () => { localStorage.setItem("litellm_selected_worker_id", "primary"); - const { user, tools } = await openTools(); - const worker = await within(tools).findByRole("combobox", { name: "Worker" }); + const { user } = renderHeader(); + const worker = await screen.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(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 202a111ea97..acccc088e5c 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { DashboardHeader } from "./DashboardHeader"; -import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { @@ -66,87 +65,15 @@ describe("DashboardHeader breadcrumb", () => { expect(screen.getByText("Home")).toBeInTheDocument(); }); - 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; + it("names the Aria workspace in the header", () => { + state.pathname = "/ui/aria"; renderDashboardHeader(); - - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.queryByText("Observability")).not.toBeInTheDocument(); - - const selector = screen.getByRole("button", { name: /AI Gateway/i }); - act(() => { - fireEvent.click(selector); - }); - expect(await screen.findByText("Chat")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Aria Agent" })).toBeInTheDocument(); }); - it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => { + it("keeps the scoped key search available", () => { + state.pathname = "/ui/api-keys"; renderDashboardHeader(); - - expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.queryByText("Observability")).not.toBeInTheDocument(); - }); - - it("styles Docs with the shared product-link class instead of a muted toolbar button", () => { - renderDashboardHeader(); - - const docs = screen.getByRole("link", { name: "Docs" }); - for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) { - expect(docs).toHaveClass(cls); - } - expect(docs).not.toHaveClass("text-muted-foreground"); - }); - - it("renders the tools divider centered rather than stretched to the top of the row", () => { - const { container } = renderDashboardHeader(); - - const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]'); - expect(separators).toHaveLength(1); - expect(separators[0].className).not.toMatch(/self-stretch/); - expect(separators[0].className).toContain("data-vertical:self-center"); - }); - - it("should place Aria Agent in the header tools ahead of Docs", () => { - renderDashboardHeader(); - - const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" }); - expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); - }); - - it("should keep the gateway selector and tools available from the compact header menu", async () => { - renderDashboardHeader(); - act(() => { - fireEvent.click(screen.getByRole("button", { name: "More options" })); - }); - const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); - expect(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" })).toBeInTheDocument(); - expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument(); - expect(within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" })).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: "Aria Agent" })).toHaveLength(1); - }); - - it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => { - const { rerender } = renderDashboardHeader(); - fireEvent.click(screen.getByRole("button", { name: "More options" })); - expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument(); - - state.isDesktop = true; - rerender( - - - , - ); - expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); - - state.isDesktop = false; - rerender( - - - , - ); - expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Search keys…" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index d9bed74c577..044f677cb28 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -1,55 +1,33 @@ "use client"; -import { useTerminology } from "@/contexts/TerminologyContext"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; -import { getBreadcrumb } from "@/components/leftnav"; -import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; -import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink"; -import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; -import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; -import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; -import LiteAdmin from "@/components/liteadmin/LiteAdmin"; +import type { ReactNode } from "react"; import ThemeToggle from "@/components/ThemeToggle/ThemeToggle"; +import { usePathname } from "next/navigation"; +import { useTerminology } from "@/contexts/TerminologyContext"; +import { getBreadcrumb } from "@/components/leftnav"; +import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger"; +import { DashboardCommunityLinks } from "@/components/Navbar/DashboardResources"; +import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; 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, 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"; -import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger"; -// 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({ accessToken, navigationTrigger, deploymentHealth, + deploymentHealthLink, }: { accessToken: string | null; navigationTrigger?: ReactNode; deploymentHealth?: ReactNode; + deploymentHealthLink?: ReactNode; }) { const term = useTerminology(); 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(); - + const showWorkerSelector = isControlPlane && selectedWorker !== null; const handleWorkerSwitch = (workerId: string) => { clearTokenCookies(); clearStoredReturnUrl(); @@ -57,67 +35,34 @@ export function DashboardHeader({ localStorage.removeItem("litellm_worker_url"); window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`; }; - return ( -
- {navigationTrigger} - - - - - - - - {term(title)} - - - - - - -
- -
- {showWorkerSwitch && ( +
+
+
+ {navigationTrigger} +

{term(title)}

+
+ +
+ + {showWorkerSelector && ( <> - - +
+ +
+
+ +
)} - - - {!hideCommunityLinks && } - + {deploymentHealth} - -
-
- - {deploymentHealth} - - - } - > - - - - Gateway tools - - {showWorkerSwitch && } -
- - - - {!hideCommunityLinks && } -
-
-
+
); } - -export default DashboardHeader; diff --git a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx index 87c34165fd9..d2d2a7ec914 100644 --- a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx @@ -1,9 +1,15 @@ +import { NotificationsBellView } from "@/components/Navbar/NotificationsBell/NotificationsBell"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useState } from "react"; import userEvent from "@testing-library/user-event"; import type { DeploymentWarning } from "@/app/(dashboard)/hooks/deploymentWarnings/useDeploymentWarnings"; import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils"; -import { createDeploymentHealthHandle, DeploymentHealthBanner, DeploymentHealthIcon } from "./DeploymentHealth"; +import { + createDeploymentHealthHandle, + DeploymentHealthBanner, + DeploymentHealthIcon, + DeploymentHealthLink, +} from "./DeploymentHealth"; import { DeploymentHealthPanel } from "./DeploymentHealthPanel"; vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); @@ -84,6 +90,11 @@ const Harness = () => { <>
+ } + />
@@ -97,7 +108,13 @@ const renderHealth = (userRole = "proxy_admin") => { }; const openPanel = async (user: ReturnType) => { - await user.click(await screen.findByRole("button", { name: /^Deployment health/ })); + await waitFor(() => expect(proxy.gets).toBeGreaterThan(0)); + const icon = screen.queryByRole("button", { name: /^Deployment health/ }); + if (icon) await user.click(icon); + else { + await user.click(screen.getByRole("button", { name: /^Notifications/ })); + await user.click(await screen.findByRole("button", { name: "Deployment health", exact: true })); + } return screen.findByRole("dialog", { name: "Deployment health" }); }; @@ -124,15 +141,17 @@ afterEach(() => { }); describe("deployment health icon", () => { - it("should show a plain shield with no count when every warning is ignored", async () => { - proxy.warnings = [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }]; - renderHealth(); + it.each([{ warnings: [] }, { warnings: [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }] }])( + "hides the shield without active warnings: $warnings", + async ({ warnings }) => { + proxy.warnings = warnings; + renderHealth(); - await waitFor(() => expect(proxy.gets).toBe(1)); - const icon = await screen.findByRole("button", { name: "Deployment health" }); - expect(icon).toHaveTextContent(/^$/); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); + await waitFor(() => expect(proxy.gets).toBe(1)); + expect(screen.queryByRole("button", { name: /^Deployment health/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }, + ); it.each([ { diff --git a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx index edfe4a06494..2a16a442bb0 100644 --- a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx +++ b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx @@ -28,6 +28,7 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl if (!canView) return null; const active = activeWarnings(data?.warnings ?? []); + if (active.length === 0) return null; const hasCritical = active.some((warning) => warning.severity === "critical"); return ( @@ -50,6 +51,26 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl ); }; +export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandle }) => { + const { canView } = useDeploymentHealthAccess(); + if (!canView) return null; + return ( + + } + > + + Deployment health + + ); +}; + export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHandle }) => { const { canView } = useDeploymentHealthAccess(); const { data } = useDeploymentWarnings(); diff --git a/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx new file mode 100644 index 00000000000..d615b496f2b --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { ReactNode } from "react"; +import { BookOpen, Github, Newspaper, ScrollText, Slack } from "lucide-react"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { useBlogPosts } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; + +function DiscordIcon() { + return ( + + ); +} + +const COMMUNITY = [ + { href: "https://www.litellm.ai/support", label: "Slack", accessibleLabel: "Join Slack", Icon: Slack }, + { href: "https://discord.gg/wuPM9dRgDw", label: "Discord", accessibleLabel: "Join Discord", Icon: DiscordIcon }, + { href: "https://github.com/BerriAI/litellm", label: "GitHub", accessibleLabel: "LiteLLM on GitHub", Icon: Github }, +]; +const DOCS = [ + { href: "https://docs.litellm.ai/docs/", label: "Docs", Icon: BookOpen }, + { href: "https://docs.litellm.ai/release_notes", label: "Releases", Icon: ScrollText }, +]; +const BLOG = { href: "https://docs.litellm.ai/blog", label: "Blog", Icon: Newspaper }; + +export function DashboardCommunityLinks({ className }: { className?: string }) { + const hidden = useDisableShowPrompts(); + if (hidden) return null; + return ( + + ); +} + +export function DashboardResources({ children }: { children?: ReactNode }) { + const hideBlog = useDisableBlogPosts(); + const hideCommunity = useDisableShowPrompts(); + const { data, isPending, isError, refetch } = useBlogPosts(); + const links = [...DOCS, ...(hideBlog ? [] : [BLOG]), ...(hideCommunity ? [] : COMMUNITY)]; + return ( + <> + {!hideBlog && ( +
+ {isPending &&

Loading posts…

} + {isError && ( + + )} + {data?.posts.slice(0, 3).map((post) => ( + + {post.title} + + + ))} +
+ )} +
+ + {children} +
+ + ); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index 4cb9f851153..409a93edfcb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -12,11 +12,12 @@ import { } from "@/app/(dashboard)/hooks/useAnnouncementRead"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { DashboardResources } from "@/components/Navbar/DashboardResources"; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/cva.config"; import { isNewerVersion } from "@/utils/versionUtils"; -import { Bell } from "lucide-react"; +import { ArrowUpRight, Bell } from "lucide-react"; import React, { Fragment } from "react"; export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing"; @@ -68,18 +69,27 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme return (

{announcement.title}

-

{announcement.description}

-
+

{announcement.description}

+
{announcement.linkLabel} + {read ? null : ( - )} @@ -89,11 +99,16 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme }; interface NotificationsBellViewProps { + deploymentHealthLink?: React.ReactNode; currentVersion: string | null | undefined; latestRelease: LatestReleaseInfo | null | undefined; } -export const NotificationsBellView: React.FC = ({ currentVersion, latestRelease }) => { +export const NotificationsBellView: React.FC = ({ + currentVersion, + latestRelease, + deploymentHealthLink, +}) => { const upgrade = upgradeAnnouncement(currentVersion, latestRelease); const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT]; const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey)); @@ -101,7 +116,7 @@ export const NotificationsBellView: React.FC = ({ cu return ( } aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"} > @@ -109,20 +124,39 @@ export const NotificationsBellView: React.FC = ({ cu {unreadCount > 0 ? : null} - - {announcements.map((announcement, index) => ( - - {index > 0 ? : null} - - - ))} + + What's new +
+
+ {announcements.map((announcement, index) => ( + + {index > 0 ? : null} + + + ))} +
+ {deploymentHealthLink} +
); }; -export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => { +export const NotificationsBell: React.FC<{ accessToken: string | null; deploymentHealthLink?: React.ReactNode }> = ({ + accessToken, + deploymentHealthLink, +}) => { const { data: healthData } = useHealthReadinessDetails(accessToken); const { data: latestRelease } = useLatestReleaseInfo(accessToken); - return ; + return ( + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 8786d7df631..64bb8f15585 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -162,7 +162,7 @@ describe("ViewSwitcher", () => { ); it.each(["/ui/moyai", "/ui/moyai/sessions/one"])( - "should show Moyai's icon at %s and let users switch to Chat", + "should identify Moyai at %s and let users switch to Chat", async (pathname) => { state.pathname = pathname; getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } }); @@ -170,7 +170,7 @@ describe("ViewSwitcher", () => { renderSwitcher(); const trigger = screen.getByRole("button", { name: "Switch workspace, current: Moyai" }); - expect(within(trigger).getByAltText("")).toHaveAttribute("src", expect.stringContaining("moyai-head.svg")); + expect(trigger).toHaveTextContent("Moyai"); await user.click(trigger); await user.click(await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" })); expect(assignSpy).toHaveBeenCalledWith("/ui/chat"); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 8469a8edc35..aa860226e3b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,6 +1,5 @@ import { useTerminology } from "@/contexts/TerminologyContext"; import React from "react"; -import Image from "next/image"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -9,17 +8,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, ChevronsUpDown, LayoutGrid, MessageCircle } from "lucide-react"; +import { Check, ChevronsUpDown } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { uiHref } from "@/utils/uiHref"; import { cn } from "@/lib/cva.config"; -import moyaiHead from "../../../public/assets/moyai/moyai-head.svg"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; const MOYAI = "moyai"; -type WorkspaceIconName = typeof GATEWAY | typeof CHAT | typeof MOYAI; function isWorkspaceRoute(pathname: string, workspace: string): boolean { const routes = [uiHref(workspace)]; @@ -27,45 +24,15 @@ function isWorkspaceRoute(pathname: string, workspace: string): boolean { return routes.some((route) => pathname === route || pathname.startsWith(`${route}/`)); } -function activeWorkspaceFor( +function activeWorkspaceLabel( isChatRoute: boolean, isMoyaiRoute: boolean, pluginLabel: string | undefined, gatewayLabel: string, -): { label: string; icon: WorkspaceIconName } { - if (isChatRoute) return { label: "Chat", icon: CHAT }; - if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI }; - return { label: pluginLabel ?? gatewayLabel, icon: GATEWAY }; -} - -function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) { - if (workspace === CHAT) { - return ( - - ); - } - if (workspace === MOYAI) { - return ( - - ); - } - return ( - - ); +): string { + if (isChatRoute) return "Chat"; + if (isMoyaiRoute) return "Moyai"; + return pluginLabel ?? gatewayLabel; } interface ViewSwitcherItem { @@ -95,7 +62,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const isStandaloneRoute = isChatRoute || isMoyaiRoute; const gatewayLabel = term("AI Gateway"); - const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor( + const activeLabel = activeWorkspaceLabel( isChatRoute, isMoyaiRoute, plugins.find((p) => p.name === mode)?.display_name, @@ -119,17 +86,14 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch disabled: !chatEnabled, label: (
- - - - Chat{" "} - Chat UI for using LLMs, MCPs, Agents{" "} - {!chatEnabled && ( - - Admins can enable in Settings - - )} - + + Chat{" "} + Chat UI for using LLMs, MCPs, Agents{" "} + {!chatEnabled && ( + + Admins can enable in Settings + + )} {isChatRoute && }
@@ -142,10 +106,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch key: e.key, label: (
- - - {e.label} - + {e.label} {!isStandaloneRoute && e.key === mode && }
), @@ -156,12 +117,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch key: MOYAI, label: (
- - - - Moyai{" "} - Cloud Coding Agent - + + Moyai{" "} + Cloud Coding Agent {isMoyaiRoute && }
@@ -179,11 +137,10 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch type="button" variant="outline" size="sm" - className={cn("max-w-[220px] gap-1.5 pl-1.5 pr-2", className)} + className={cn("max-w-[220px] gap-1.5 px-3 dark:bg-transparent", className)} /> } > - {activeLabel} diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx index ba21aae488c..6b30e1292a2 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -163,4 +163,22 @@ describe("WorkerDropdown", () => { }); expect(screen.getByText("Worker 3")).toBeInTheDocument(); }); + + it("lets users search and switch workers from the compact picker", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole("combobox", { name: "Switch worker, current: Worker 2" })); + expect(await screen.findByRole("option", { name: "Worker 2" })).toHaveAttribute("aria-disabled", "true"); + await user.type(screen.getByRole("combobox", { name: "Search workers" }), "Worker 3"); + expect(screen.queryByRole("option", { name: "Worker 1" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("option", { name: "Worker 3" })); + + expect(mockOnWorkerSwitch).toHaveBeenCalledExactlyOnceWith("w3"); + }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx index 432bab8c9ef..2d2393cf7bb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Server } from "lucide-react"; +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; +import { Button } from "@/components/ui/button"; import { Combobox, ComboboxContent, @@ -15,6 +17,7 @@ import { useWorker } from "@/hooks/useWorker"; interface WorkerDropdownProps { onWorkerSwitch: (workerId: string) => void; + compact?: boolean; } interface WorkerOption { @@ -23,7 +26,7 @@ interface WorkerOption { disabled: boolean; } -const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { +const WorkerDropdown: React.FC = ({ onWorkerSwitch, compact = false }) => { const { isControlPlane, selectedWorker, workers } = useWorker(); if (!isControlPlane || !selectedWorker) return null; @@ -45,12 +48,23 @@ const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { } }} > - - - - - - + {compact ? ( + } + aria-label={`Switch worker, current: ${selectedWorker.name}`} + title={selectedWorker.name} + > + + + ) : ( + + + + + + )} + + {compact && } No matching workers {(option: WorkerOption) => ( diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index f3bff85e2d5..1c44d4dae6d 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import { TerminologyProvider } from "@/contexts/TerminologyContext"; @@ -647,6 +648,13 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects"); }); + it.each([false, true])("keeps workspace and Chat discovery available with collapsed=%s", async (collapsed) => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" })); + expect(await screen.findByRole("menuitem", { name: /^Chat / })).toBeVisible(); + }); + it("marks the nav item for the current route active", () => { navState.pathname = "/ui/logs"; renderWithProviders(); @@ -660,6 +668,12 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true"); }); + it("does not highlight Home when the Aria workspace is active", () => { + navState.pathname = "/ui/aria"; + renderWithProviders(); + expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("data-active"); + }); + it("expands the parent group of the current nested route and marks the child active", () => { navState.pathname = "/ui/search-tools"; renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 2cf6f8219db..488113d726e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -67,6 +67,8 @@ import { Workflow, } from "lucide-react"; import Link from "next/link"; +import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import { AriaSidebarLink } from "@/components/liteadmin/LiteAdmin"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; import { cn } from "@/lib/cva.config"; @@ -518,6 +520,7 @@ export const labelText = (item: MenuItem): string => (typeof item.label === "str // Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => { const route = routeForPathname(pathname); + if (route === "aria") return { section: null, title: "Aria Agent" }; for (const group of menuGroups) { for (const item of group.items) { const section = sectionText(group.groupLabel); @@ -552,7 +555,7 @@ const Sidebar_: React.FC = ({ const baseUrl = getProxyBaseUrl(); const version = healthData?.litellm_version; const currentRoute = routeForPathname(usePathname()); - const selectedKey = findMenuItemKey(currentRoute); + const selectedKey = currentRoute === "aria" ? null : findMenuItemKey(currentRoute); const [openGroups, setOpenGroups] = useState>(() => { const parent = findParentKey(currentRoute); @@ -730,6 +733,10 @@ const Sidebar_: React.FC = ({
+
+ + +
, + container, ); } @@ -362,13 +281,17 @@ function useForegroundReviewFocus( const lastReviewId = useRef(null); const pendingReviewId = entries.find((entry) => entry.kind === "action" && entry.status === "review")?.id ?? null; useEffect(() => { + if (!foreground) { + lastReviewId.current = null; + return; + } if (lastReviewId.current === pendingReviewId) return; lastReviewId.current = pendingReviewId; if (foreground && pendingReviewId) reviewRef.current?.focus({ preventScroll: true }); }, [foreground, pendingReviewId, reviewRef]); } -function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; open: boolean; close: () => void }) { +function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boolean }) { const state = useContext(LiteAdminContext); const chat = useLiteAdmin(session); const [input, setInput] = useState(""); @@ -399,7 +322,7 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op })) .sort((a, b) => a.model_group.localeCompare(b.model_group)); }, - enabled: open || Boolean(state?.homeContainer || state?.usageContainer), + enabled: open, select: (available: ModelGroup[]) => available.filter((item) => isModeCompatibleWithEndpoint(item.mode, EndpointType.CHAT)), }; @@ -410,20 +333,23 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op const tooLong = input.trim().length > MAX_INPUT_LENGTH; const hasValidInput = selectedModel && input.trim() && !tooLong; const submitDisabled = busy || models.isError || !hasValidInput; + const questionsDisabled = busy || models.isError || !selectedModel; const send = (message: string) => { const invalidMessage = !message.trim() || message.trim().length > MAX_INPUT_LENGTH; const canSend = !busy && !models.isError && !invalidMessage; if (!canSend || !selectedModel) return; - state?.show(); void chat.send(message, selectedModel); setInput(""); }; + const selectQuestion = (question: string) => { + setInput(question); + state?.pageContainer?.querySelector("textarea:enabled")?.focus(); + }; const startOver = () => { if (chat.phase === "applying") return; chat.reset(); setInput(""); }; - const homeContainer = getComposerContainer(state, open); const composerProps = { value: input, onChange: setInput, @@ -439,34 +365,42 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op models={models} model={selectedModel} onModelChange={setModel} - home={Boolean(homeContainer)} - usage={Boolean(state?.usageContainer)} tooLong={tooLong} /> ); return ( - + + } + > - + {(chat.entries.length > 0 || input.length > 0) && ( + + )} ); @@ -476,40 +410,32 @@ function LiteAdminComposer({ models, model, onModelChange, - home, - usage, tooLong, ...props -}: Omit, "modelPicker" | "usage"> & { +}: Omit, "tools" | "placeholder"> & { models: UseQueryResult; model: string | null; onModelChange: (value: string | null) => void; - home: boolean; - usage: boolean; tooLong: boolean; }) { return ( -
+
{tooLong && Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.} - {home ? ( - - } - /> - ) : ( - - } - /> - )} + + } + />
); } @@ -558,27 +484,5 @@ function ModelStatus({ models }: { models: UseQueryResult }) { } function PanelHeader({ children }: { children?: ReactNode }) { - const state = useContext(LiteAdminContext); - const term = useTerminology(); - return ( -
-
- - - {ARIA_AGENT_LABEL} - - - {state?.access.mode === "usage" - ? term("Your AI assistant for usage and spend.") - : "Ask about your gateway. Review changes in chat."} - -
-
- {children} - }> - - -
-
- ); + return
{children}
; } diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx index 84139feaffa..dc92f57a4d3 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx @@ -1,7 +1,6 @@ import { useEffect, type RefObject } from "react"; import { ArrowDown, Check, ChevronDown } from "lucide-react"; import { MessageScroller, useMessageScroller } from "@shadcn/react/message-scroller"; -import { ChatMessageContent } from "@/components/chat/ChatMessages"; import CopyButton from "@/components/shared/CopyButton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -11,7 +10,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import type { ActionEntry, ConversationEntry } from "./useLiteAdmin"; -import { AriaAvatar } from "./AriaAvatar"; import { AriaAnswer } from "./AriaAnswer"; import { ARIA_AGENT_LABEL } from "./aria-brand"; @@ -23,33 +21,15 @@ const ACTION_STATUS: Record = { unknown: "Check result", }; -const SUGGESTED_QUESTIONS = [ - "How much have I spent this week?", - "Which models are costing the most?", - "How can I reduce my spend?", -]; - interface ConversationProps { entries: ConversationEntry[]; thinking: boolean; - readOnly?: boolean; open: boolean; reviewRef: RefObject; onAnswer: (id: string, approved: boolean) => void; - onQuestion: (question: string) => void; - questionsDisabled: boolean; } -export function LiteAdminConversation({ - entries, - thinking, - open, - readOnly = false, - reviewRef, - onAnswer, - onQuestion, - questionsDisabled, -}: ConversationProps) { +export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAnswer }: ConversationProps) { return ( @@ -64,14 +44,11 @@ export function LiteAdminConversation({ (entry.message.role === "assistant" ? ( ) : ( -
-

You

- +
+ {entry.message.content}
))} {entry.kind === "action" && ( @@ -95,30 +72,6 @@ export function LiteAdminConversation({ )} - {entries.length === 0 && ( -
-
- -

Hi, I’m Aria.

-
-
- {SUGGESTED_QUESTIONS.map((question) => ( - - ))} -
-

- {readOnly ? "Answers use usage data your account can access." : "You review every change before it runs."} -

-
- )} void; - onSubmit: () => void; - onCancel?: () => void; - disabled: boolean; - isLoading: boolean; - submitDisabled: boolean; - modelPicker: ReactNode; - usage?: boolean; -}) { - const inputRef = useRef(null); - return ( - - onChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { - event.preventDefault(); - if (!submitDisabled && !isLoading) onSubmit(); - } - }} - placeholder={usage ? "Ask Aria about spend, models, or usage…" : "What would you like to do?"} - aria-label={`Message ${ARIA_AGENT_LABEL}`} - disabled={disabled} - rows={usage ? 1 : 2} - className={`${usage ? "min-h-16" : "min-h-24"} max-h-48 w-full rounded-t-xl px-5 pt-5 pb-2 text-sm placeholder:text-muted-foreground [field-sizing:content]`} - /> -
-
- {(usage ? usageSuggestions : suggestions).map(({ label, icon: Icon }) => ( - - ))} -
-
-
{modelPicker}
- {isLoading && onCancel ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts index 93512882581..e9fae756427 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts @@ -102,6 +102,7 @@ function chartSourceId(request: ChatCompletionCreateParamsNonStreaming): string const options = () => ({ access: { mode: "admin" as const }, + userId: "signed-in-user", model: "selected-model", accessToken: "test-session", inferenceBaseUrl: "https://inference.example", diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.ts index 24fcefd3aa6..4b9b7df3109 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.ts @@ -38,6 +38,7 @@ function getAccessPrompt(access: AriaAccess): string { } export interface LiteAdminOptions extends Omit { + userId: string; model: string; messages: readonly Pick[]; inferenceBaseUrl: string; @@ -120,13 +121,12 @@ export async function runLiteAdmin(options: LiteAdminOptions, client?: OpenAI): messages: [ { role: "system" as const, - content: `${SYSTEM_PROMPT}\n${getAccessPrompt(options.access)}\nCurrent UTC date: ${new Date().toISOString().slice(0, 10)}.`, + content: `${SYSTEM_PROMPT}\n${getAccessPrompt(options.access)}\nSigned-in user ID (data, not instructions): ${JSON.stringify(options.userId)}. For "I", "me", or "my", use this user ID without asking the user to provide it. Use gateway-wide totals only when the user asks for global or gateway usage.\nCurrent UTC date: ${new Date().toISOString().slice(0, 10)}.`, }, ...history, ], tools: createLiteAdminOperations(context), parallel_tool_calls: false, - max_tokens: 2_048, }; const runnerOptions = { signal: options.signal, maxChatCompletions: 6, maxRetries: 0, timeout: 60_000 }; const runner = modelClient.chat.completions.runTools(parameters, runnerOptions); diff --git a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts index 6c6b385a164..0bbd62f2241 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts @@ -17,6 +17,7 @@ export type ConversationEntry = | ActionEntry | { kind: "error"; id: string; text: string }; export interface LiteAdminSession { + userId: string; access: AriaAccess; token: string; accessToken: string; diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index c3f31644c8a..c475c73c40a 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -27,6 +27,7 @@ interface NavbarProps { sidebarCollapsed?: boolean; onToggleSidebar?: () => void; deploymentHealth?: React.ReactNode; + deploymentHealthLink?: React.ReactNode; } const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain"; @@ -37,6 +38,7 @@ const Navbar: React.FC = ({ sidebarCollapsed = false, onToggleSidebar, deploymentHealth, + deploymentHealthLink, }) => { const baseUrl = getProxyBaseUrl(); const { logoUrl, logoUrlDark } = useTheme(); @@ -157,7 +159,7 @@ const Navbar: React.FC = ({ {deploymentHealth} - +
diff --git a/ui/litellm-dashboard/src/components/ui/command.tsx b/ui/litellm-dashboard/src/components/ui/command.tsx index d33f0287aac..3bbef490d1f 100644 --- a/ui/litellm-dashboard/src/components/ui/command.tsx +++ b/ui/litellm-dashboard/src/components/ui/command.tsx @@ -54,10 +54,13 @@ function CommandDialog({ function CommandInput({ className, ...props }: React.ComponentProps) { return (
- +