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
This commit is contained in:
moe-berri 2026-11-04 16:56:06 +08:00 • committed by GitHub
parent bd8eeedfc2
commit 664241188f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 776 additions and 1262 deletions

View file

@ -0,0 +1,5 @@
import { LiteAdminPage } from "@/components/liteadmin/LiteAdmin";
export default function AriaPage() {
return <LiteAdminPage />;
}

View file

@ -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(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<LiteAdminFrame>
@ -152,9 +153,8 @@ describe("HomePage", () => {
</LiteAdminFrame>
</QueryClientProvider>,
);
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();
});

View file

@ -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() {
)}
</PageHeader>
<LiteAdminHome />
<section aria-label={`Usage, last ${HOME_USAGE_DAYS} days`}>
{usage.failed ? (
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3 text-sm">

View file

@ -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={<DeploymentHealthIcon handle={deploymentHealthHandle} />}
deploymentHealthLink={<DeploymentHealthLink handle={deploymentHealthHandle} />}
/>
<DeploymentHealthBanner handle={deploymentHealthHandle} />
<DeploymentHealthPanel handle={deploymentHealthHandle} />
@ -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 (
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
<div className="hidden h-full md:flex">
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
</div>
<Sheet
handle={mobileNavigationHandle}
open={mobileNavigationOpen}
onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}
>
<SheetContent
side="left"
showCloseButton={false}
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
onClickCapture={(event) => {
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
}}
<LiteAdminFrame>
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
<div className="hidden h-full md:flex">
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
</div>
<Sheet
handle={mobileNavigationHandle}
open={mobileNavigationOpen}
onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
</Sheet>
<LiteAdminFrame>
<SheetContent
side="left"
showCloseButton={false}
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
onClickCapture={(event) => {
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
}}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
</Sheet>
<CommandPaletteProvider>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
@ -203,18 +205,25 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
</SheetTrigger>
}
deploymentHealth={<DeploymentHealthIcon handle={deploymentHealthHandle} />}
deploymentHealthLink={<DeploymentHealthLink handle={deploymentHealthHandle} />}
/>
<DeploymentHealthBanner handle={deploymentHealthHandle} />
<DeploymentHealthPanel handle={deploymentHealthHandle} />
<UserBanner accessToken={accessToken} />
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<main
className={
routeSegment === "aria"
? "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
: "min-h-0 min-w-0 flex-1 overflow-y-auto"
}
>
<TelemetryEnvBanner />
{children}
</main>
</div>
</CommandPaletteProvider>
</LiteAdminFrame>
</div>
</div>
</LiteAdminFrame>
);
}

View file

@ -248,8 +248,7 @@ vi.mock("@/components/EntityUsageExport", () => ({
}));
vi.mock("@/components/liteadmin/LiteAdmin", () => ({
AriaUsageTrigger: () => <button aria-label="Open Aria Agent">Aria</button>,
AriaUsageComposer: () => <section aria-label="Ask Aria Agent">Shared Aria composer</section>,
AriaUsageTrigger: () => <a href="/aria">Ask Aria</a>,
}));
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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();
});
});

View file

@ -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<UsagePageProps> = ({ teams, organizations, teamsLoadin
</div>
</div>
</header>
<AriaUsageComposer />
{aggregatedFailed && (
<ErrorState title="Failed to load spend data" message={aggregatedError} onRetry={reloadAggregated} />
)}

View file

@ -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 })
<Button
variant="ghost"
size="icon"
className="size-11 md:hidden"
className={cn("text-muted-foreground", className ?? "size-11 md:hidden")}
aria-label="Search"
aria-expanded={open}
onClick={() => setOpen(true)}
@ -33,8 +34,11 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
return (
<Button
variant="outline"
className="hidden w-64 justify-between md:flex lg:w-80"
variant="ghost"
className={cn(
"justify-between bg-muted/50 font-normal text-muted-foreground hover:bg-muted",
className ?? "hidden w-64 md:flex lg:w-80",
)}
aria-label={label}
aria-expanded={open}
onClick={() => setOpen(true)}
@ -43,7 +47,7 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
<Search />
<span className="truncate">{label}</span>
</span>
<Kbd>{shortcut}</Kbd>
<Kbd className="bg-background/70 font-normal">{shortcut}</Kbd>
</Button>
);
}

View file

@ -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() {
</PluginModeProvider>
</QueryClientProvider>,
);
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();
});
});

View file

@ -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(
<CommandPaletteProvider>
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
state.isDesktop = false;
rerender(
<CommandPaletteProvider>
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search keys…" })).toBeInTheDocument();
});
});

View file

@ -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 (
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4 max-md:gap-2 max-md:px-2">
{navigationTrigger}
<Breadcrumb className="min-w-0 max-md:flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="flex-none max-md:hidden">
<ViewSwitcher />
</BreadcrumbItem>
<BreadcrumbSeparator className="max-md:hidden" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{term(title)}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<CommandPaletteTrigger />
<div className="flex shrink-0 items-center gap-1">
<LiteAdmin />
<div className="flex flex-none items-center gap-1 max-md:hidden">
{showWorkerSwitch && (
<header className="@container h-14 shrink-0 border-b border-border bg-background px-3 sm:px-5">
<div className="flex h-full items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{navigationTrigger}
<h1 className="truncate text-sm font-medium">{term(title)}</h1>
</div>
<CommandPaletteTrigger className="hidden w-56 @min-[640px]:flex @min-[1000px]:w-80" />
<div className="flex shrink-0 items-center justify-end gap-1">
<CommandPaletteTrigger mobile className="size-9 @min-[640px]:hidden" />
{showWorkerSelector && (
<>
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
<ToolbarSeparator />
<div className="@min-[840px]:hidden">
<WorkerDropdown compact onWorkerSwitch={handleWorkerSwitch} />
</div>
<div className="hidden @min-[840px]:block">
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
</div>
</>
)}
<DocsLink />
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
<ToolbarSeparator />
<DashboardCommunityLinks
className={showWorkerSelector ? "hidden @min-[840px]:flex" : "hidden @min-[400px]:flex"}
/>
<ThemeToggle />
{deploymentHealth}
<NotificationsBell accessToken={accessToken} />
</div>
<div className="flex shrink-0 items-center gap-1 md:hidden">
<CommandPaletteTrigger mobile />
{deploymentHealth}
<NotificationsBell accessToken={accessToken} />
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
<PopoverTrigger
render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}
>
<Ellipsis />
</PopoverTrigger>
<PopoverContent align="end" className="max-w-[calc(100vw-2rem)]">
<PopoverTitle>Gateway tools</PopoverTitle>
<ViewSwitcher />
{showWorkerSwitch && <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />}
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle />
<DocsLink />
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
</div>
</PopoverContent>
</Popover>
<NotificationsBell accessToken={accessToken} deploymentHealthLink={deploymentHealthLink} />
</div>
</div>
</header>
);
}
export default DashboardHeader;

View file

@ -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 = () => {
<>
<header>
<DeploymentHealthIcon handle={handle} />
<NotificationsBellView
currentVersion={null}
latestRelease={null}
deploymentHealthLink={<DeploymentHealthLink handle={handle} />}
/>
</header>
<DeploymentHealthBanner handle={handle} />
<DeploymentHealthPanel handle={handle} />
@ -97,7 +108,13 @@ const renderHealth = (userRole = "proxy_admin") => {
};
const openPanel = async (user: ReturnType<typeof userEvent.setup>) => {
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([
{

View file

@ -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 (
<SheetTrigger
handle={handle}
render={
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-xs font-normal text-muted-foreground"
/>
}
>
<Shield className="size-4" aria-hidden />
Deployment health
</SheetTrigger>
);
};
export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHandle }) => {
const { canView } = useDeploymentHealthAccess();
const { data } = useDeploymentWarnings();

View file

@ -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 (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.32 4.37a19.8 19.8 0 0 0-4.89-1.52.07.07 0 0 0-.08.04c-.21.38-.44.87-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.6 12.6 0 0 0-.62-1.25.08.08 0 0 0-.08-.04 19.74 19.74 0 0 0-4.89 1.52.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .08-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13l.37-.29a.07.07 0 0 1 .08-.01c3.93 1.79 8.18 1.79 12.06 0a.07.07 0 0 1 .08.01l.37.29a.08.08 0 0 1-.01.13c-.6.35-1.22.65-1.87.89a.08.08 0 0 0-.04.11c.36.7.78 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .03-.05c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.03-.03ZM8.02 15.33c-1.18 0-2.16-1.08-2.16-2.42 0-1.33.96-2.42 2.16-2.42 1.21 0 2.18 1.1 2.16 2.42 0 1.34-.96 2.42-2.16 2.42Zm7.97 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.21 0 2.18 1.1 2.16 2.42 0 1.34-.95 2.42-2.16 2.42Z" />
</svg>
);
}
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 (
<nav aria-label="Community links" className={cn("items-center gap-0.5", className)}>
<TooltipProvider>
{COMMUNITY.map(({ href, label, accessibleLabel, Icon }) => (
<Tooltip key={label}>
<TooltipTrigger
render={
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={accessibleLabel}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "text-muted-foreground")}
/>
}
>
<Icon />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
<ToolbarSeparator className="mx-1.5 h-4 bg-foreground/15" />
</nav>
);
}
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 && (
<section aria-label="Latest posts" className="border-t border-border px-2 py-2">
{isPending && <p className="px-2 py-3 text-xs text-muted-foreground">Loading posts…</p>}
{isError && (
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
Retry loading posts
</Button>
)}
{data?.posts.slice(0, 3).map((post) => (
<a
key={post.url}
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-baseline justify-between gap-4 rounded-md px-2 py-2 text-[13px] leading-5 hover:bg-muted focus-visible:outline-ring"
>
<span className="line-clamp-2">{post.title}</span>
<time dateTime={post.date} className="shrink-0 text-[11px] text-muted-foreground">
{new Date(`${post.date}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</time>
</a>
))}
</section>
)}
<div className="border-t border-border bg-muted/25 p-2">
<nav aria-label="Help and resources" className="grid grid-cols-3 gap-1">
{links.map(({ href, label, Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"justify-start gap-2 text-xs font-normal text-muted-foreground",
)}
>
<Icon />
{label}
</a>
))}
</nav>
{children}
</div>
</>
);
}

View file

@ -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 (
<article aria-label={announcement.title} className="flex flex-col gap-2">
<p className="text-sm font-medium">{announcement.title}</p>
<p className="text-sm leading-snug text-muted-foreground">{announcement.description}</p>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs leading-relaxed text-muted-foreground">{announcement.description}</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<a
className={cn(buttonVariants({ size: "sm" }))}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"h-auto gap-1 p-0 text-xs font-normal text-muted-foreground hover:text-foreground",
)}
href={announcement.href}
target="_blank"
rel="noopener noreferrer"
>
{announcement.linkLabel}
<ArrowUpRight className="size-3" aria-hidden />
</a>
{read ? null : (
<Button variant="link" size="sm" className="px-1!" onClick={() => markAnnouncementRead(announcement.readKey)}>
<Button
variant="link"
size="sm"
className="h-auto px-0! text-xs font-normal text-muted-foreground"
onClick={() => markAnnouncementRead(announcement.readKey)}
>
Mark as read
</Button>
)}
@ -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<NotificationsBellViewProps> = ({ currentVersion, latestRelease }) => {
export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
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<NotificationsBellViewProps> = ({ cu
return (
<Popover>
<PopoverTrigger
className="flex! h-9! w-9! items-center justify-center rounded-md! text-muted-foreground transition-colors hover:bg-accent! hover:text-foreground!"
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
>
<span className="relative inline-flex">
@ -109,20 +124,39 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ cu
{unreadCount > 0 ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
</span>
</PopoverTrigger>
<PopoverContent align="end" aria-label="Notifications">
{announcements.map((announcement, index) => (
<Fragment key={announcement.readKey}>
{index > 0 ? <Separator /> : null}
<AnnouncementItem announcement={announcement} />
</Fragment>
))}
<PopoverContent
align="end"
aria-label="Notifications"
className="w-96 max-w-[calc(100vw-1rem)] gap-0 overflow-hidden p-0"
>
<PopoverTitle className="border-b border-border px-4 py-3 text-sm font-semibold">What&apos;s new</PopoverTitle>
<div className="max-h-[min(32rem,calc(100dvh-10rem))] overflow-y-auto">
<div className="space-y-4 p-4">
{announcements.map((announcement, index) => (
<Fragment key={announcement.readKey}>
{index > 0 ? <Separator /> : null}
<AnnouncementItem announcement={announcement} />
</Fragment>
))}
</div>
<DashboardResources>{deploymentHealthLink}</DashboardResources>
</div>
</PopoverContent>
</Popover>
);
};
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 <NotificationsBellView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
return (
<NotificationsBellView
currentVersion={healthData?.litellm_version}
latestRelease={latestRelease}
deploymentHealthLink={deploymentHealthLink}
/>
);
};

View file

@ -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");

View file

@ -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 (
<span
data-slot="workspace-icon"
aria-hidden="true"
className="flex size-6 flex-none items-center justify-center rounded-md bg-primary/10 text-primary ring-1 ring-inset ring-primary/15"
>
<MessageCircle className="size-4" />
</span>
);
}
if (workspace === MOYAI) {
return (
<span data-slot="workspace-icon" aria-hidden="true" className="flex size-6 flex-none items-center justify-center">
<Image src={moyaiHead.src} alt="" width={20} height={20} unoptimized className="size-5 object-contain" />
</span>
);
}
return (
<span
data-slot="workspace-icon"
aria-hidden="true"
className="flex size-6 flex-none items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<LayoutGrid className="size-4" />
</span>
);
): 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: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<WorkspaceIcon workspace={CHAT} />
<span className="flex max-w-[220px] flex-col">
<span className="font-medium">Chat</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
{!chatEnabled && (
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
</span>
)}
</span>
<span className="flex max-w-[220px] flex-col">
<span className="font-medium">Chat</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
{!chatEnabled && (
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
</span>
)}
</span>
{isChatRoute && <Check className="size-4 text-info" />}
</div>
@ -142,10 +106,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
key: e.key,
label: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<WorkspaceIcon workspace={GATEWAY} />
<span className="font-medium">{e.label}</span>
</span>
<span className="font-medium">{e.label}</span>
{!isStandaloneRoute && e.key === mode && <Check className="size-4 text-info" />}
</div>
),
@ -156,12 +117,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
key: MOYAI,
label: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<WorkspaceIcon workspace={MOYAI} />
<span className="flex flex-col">
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
<span className="flex flex-col">
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
{isMoyaiRoute && <Check className="size-4 text-info" />}
</div>
@ -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)}
/>
}
>
<WorkspaceIcon workspace={activeIcon} />
<span data-slot="workspace-label" className="truncate">
{activeLabel}
</span>

View file

@ -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(<WorkerDropdown compact onWorkerSwitch={mockOnWorkerSwitch} />);
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");
});
});

View file

@ -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<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch, compact = false }) => {
const { isControlPlane, selectedWorker, workers } = useWorker();
if (!isControlPlane || !selectedWorker) return null;
@ -45,12 +48,23 @@ const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
}
}}
>
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
<InputGroupAddon align="inline-start">
<Server className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent>
{compact ? (
<ComboboxPrimitive.Trigger
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
aria-label={`Switch worker, current: ${selectedWorker.name}`}
title={selectedWorker.name}
>
<Server className="size-4" aria-hidden />
</ComboboxPrimitive.Trigger>
) : (
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
<InputGroupAddon align="inline-start">
<Server className="size-4" />
</InputGroupAddon>
</ComboboxInput>
)}
<ComboboxContent align={compact ? "end" : "start"} className={compact ? "w-64" : undefined}>
{compact && <ComboboxInput aria-label="Search workers" placeholder="Search workers…" showTrigger={false} />}
<ComboboxEmpty>No matching workers</ComboboxEmpty>
<ComboboxList>
{(option: WorkerOption) => (

View file

@ -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(<Sidebar collapsed={collapsed} />);
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(<Sidebar {...defaultProps} />);
@ -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(<Sidebar {...defaultProps} />);
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(<Sidebar {...defaultProps} />);

View file

@ -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<SidebarProps> = ({
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<Set<string>>(() => {
const parent = findParentKey(currentRoute);
@ -730,6 +733,10 @@ const Sidebar_: React.FC<SidebarProps> = ({
</div>
</SidebarHeader>
<div className="space-y-1 px-3 pt-3 pb-1">
<ViewSwitcher className="h-9 w-full max-w-none justify-start shadow-none group-data-[collapsed=true]/sidebar:size-9 group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:p-0 [&_[data-slot=workspace-label]]:flex-1 [&_[data-slot=workspace-label]]:text-left group-data-[collapsed=true]/sidebar:[&_[data-slot=workspace-label]]:hidden" />
<AriaSidebarLink active={currentRoute === "aria"} />
</div>
<ScrollArea className="min-h-0 flex-1">
<nav className="flex flex-col gap-0.5 px-3 pb-3">
{visibleGroups.map((group, gi) => (

View file

@ -26,7 +26,6 @@ export function AriaAnswer({
};
return (
<article aria-label={`${ARIA_AGENT_LABEL} answer`} className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">{ARIA_AGENT_LABEL}</p>
<div ref={answerRef} className="space-y-4">
{chart && <UsageAnswerChart chart={chart} />}
<ChatMessageContent
@ -35,9 +34,16 @@ export function AriaAnswer({
isLastMessage={false}
isStreaming={false}
actions={
<Button variant="ghost" size="sm" onClick={exportAnswer} data-export-exclude>
<Button
variant="ghost"
size="icon-sm"
onClick={exportAnswer}
data-export-exclude
aria-label="Export answer"
title="Export answer"
className="text-muted-foreground"
>
<Download className="size-3.5" />
Export answer
</Button>
}
/>

View file

@ -8,14 +8,12 @@ describe("AriaAvatar", () => {
setServerRootPath("/");
});
it.each(["/", "/litellm"])("loads the bundled image when the server root is %s", (root) => {
it.each(["/", "/litellm"])("renders a decorative icon without an image request under server root %s", (root) => {
setServerRootPath(root);
render(<AriaAvatar />);
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute("aria-hidden", "true");
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute(
"src",
"/_next/static/media/aria-agent.png",
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});

View file

@ -1,17 +1,14 @@
import ariaAgentImage from "../../../public/assets/aria-agent.png";
import { Sparkle } from "lucide-react";
import { cn } from "@/lib/cva.config";
export function AriaAvatar({ size = 28, className }: { size?: number; className?: string }) {
export function AriaAvatar({ size = 20, className }: { size?: number; className?: string }) {
return (
<img
src={ariaAgentImage.src}
width={size}
height={size}
alt=""
<Sparkle
role="presentation"
aria-hidden="true"
draggable={false}
className={cn("shrink-0 object-contain [image-rendering:pixelated]", className)}
className={cn("shrink-0", className)}
style={{ width: size, height: size }}
strokeWidth={1.75}
/>
);
}

View file

@ -0,0 +1,54 @@
import type { ReactNode } from "react";
import type { AriaAccess } from "./operations";
import { ChartNoAxesColumnIncreasing, Coins, TrendingDown } from "lucide-react";
import { Button } from "@/components/ui/button";
const QUESTIONS = [
{ label: "Spend overview", question: "How much have I spent this week?", Icon: Coins },
{ label: "Cost by model", question: "Which models are costing the most?", Icon: ChartNoAxesColumnIncreasing },
{ label: "Find savings", question: "How can I reduce my spend?", Icon: TrendingDown },
];
export function AriaStart({
composer,
onQuestion,
disabled,
access,
}: {
composer: ReactNode;
onQuestion: (question: string) => void;
disabled: boolean;
access: AriaAccess;
}) {
const usageDescription =
access.mode === "usage" && access.scope.type === "user" ? "Ask about your usage" : "Ask about gateway usage";
const description = access.mode === "admin" ? "Manage your gateway · Changes require approval" : usageDescription;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-10 sm:px-6 sm:pb-[16vh]">
<div className="my-auto w-full shrink-0 space-y-6">
<div className="space-y-3">
<h2 className="text-3xl leading-tight font-medium tracking-[-0.035em] text-foreground sm:text-4xl">
What would you like to know?
</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="[&>div]:p-0">{composer}</div>
<div aria-label="Suggested questions" className="flex flex-wrap gap-2">
{QUESTIONS.map(({ label, question, Icon }) => (
<Button
key={label}
variant="outline"
aria-label={question}
disabled={disabled}
onClick={() => onQuestion(question)}
className="h-9 gap-2 rounded-full border-border bg-transparent dark:bg-transparent px-3.5 text-[13px] font-normal text-muted-foreground shadow-none hover:border-foreground/20 hover:bg-muted/50 hover:text-foreground"
>
<Icon aria-hidden="true" className="size-3.5" />
{label}
</Button>
))}
</div>
</div>
</div>
);
}

View file

@ -11,19 +11,19 @@ import userEvent from "@testing-library/user-event";
import { Fragment, useEffect, type ComponentType } from "react";
import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMenu";
import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown";
import LiteAdmin, { AriaUsageComposer, AriaUsageTrigger, LiteAdminFrame, LiteAdminHome } from "./LiteAdmin";
import { AriaUsageTrigger, LiteAdminFrame, LiteAdminPage, AriaSidebarLink } from "./LiteAdmin";
import { MAX_INPUT_LENGTH } from "./agent";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
const { transport } = vi.hoisted(() => {
const { transport, push } = vi.hoisted(() => {
const transport = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", transport);
return { transport };
return { transport, push: vi.fn() };
});
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
vi.unmock("@/lib/toast");
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
const MANAGEMENT = "https://management.test/proxy";
const INFERENCE = "https://management.test/inference";
@ -108,11 +108,11 @@ function renderWidget(
Menu?: ComponentType<{ onLogout: () => void }>,
showHome = false,
showUsage = false,
withTerminology = false,
{ withTerminology = false, showPage = true }: { withTerminology?: boolean; showPage?: boolean } = {},
) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const Terminology = withTerminology ? TerminologyProvider : Fragment;
const tree = (home = showHome, usage = showUsage) => (
const tree = (_home = showHome, usage = showUsage, page = showPage) => (
<QueryClientProvider client={client}>
<Toaster />
<Terminology>
@ -121,11 +121,10 @@ function renderWidget(
{Menu && <Menu onLogout={() => undefined} />}
<LiteAdminFrame>
<Page />
{home && <LiteAdminHome />}
<LiteAdmin />
<AriaSidebarLink active={page} />
{page && <LiteAdminPage />}
{usage && (
<>
<AriaUsageComposer />
<AriaUsageTrigger />
</>
)}
@ -135,7 +134,11 @@ function renderWidget(
</QueryClientProvider>
);
const view = render(tree());
return { ...view, refresh: (home = showHome, usage = showUsage) => view.rerender(tree(home, usage)), client };
return {
...view,
refresh: (home = showHome, usage = showUsage, page = showPage) => view.rerender(tree(home, usage, page)),
client,
};
}
interface GatewayOptions {
@ -226,7 +229,6 @@ async function selectModel(model = "chat-model") {
}
async function openWidget() {
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
await selectModel();
}
@ -238,6 +240,7 @@ function send(text: string) {
beforeEach(() => {
pageMounts = 0;
transport.mockReset();
push.mockReset();
localStorage.clear();
sessionStorage.clear();
switchToWorkerUrl(null);
@ -251,17 +254,57 @@ afterEach(() => {
});
describe("Aria Agent in the gateway", () => {
it.each([true, false])(
"keeps the answer, draft and model when returning to Aria with premium=%s",
async (premium) => {
session("proxy_admin", "first-admin", { premium_user: premium });
gateway([answer("Your usage is within budget.")]);
const view = renderWidget();
await selectModel();
fireEvent.keyDown(document, { key: "j", metaKey: true });
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus();
send("Check my usage");
await screen.findByText("Your usage is within budget.");
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "What changed?" } });
view.refresh(false, false, false);
expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
expect(push).toHaveBeenCalledWith("/ui/aria");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
view.refresh(false, false, true);
expect(screen.getByText("Your usage is within budget.")).toBeVisible();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("What changed?");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByRole("region", { name: "Aria Agent", exact: true })).toBeVisible();
},
);
it("links Usage and the sidebar to one page without mounting a composer elsewhere", async () => {
const requests = gateway([]);
const { client } = renderWidget(undefined, false, true, { showPage: false });
expect(await screen.findByRole("link", { name: "Ask Aria" })).toHaveAttribute("href", "/ui/aria");
expect(screen.getByRole("link", { name: "Aria Agent" })).toHaveAttribute("href", "/ui/aria");
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(requests.some((r) => r.url.endsWith("/models") || r.url.endsWith("/sso/get/ui_settings"))).toBe(false);
});
it.each(["proxy_admin", "internal_user"])(
"should start a conversation from the exact suggested question without changing %s permissions",
"prefills suggestions without sending and submits the edited draft with %s permissions",
async (role) => {
session(role);
const requests = gateway([answer("Here is your weekly spend.")]);
renderWidget();
await userEvent.setup().click(await screen.findByRole("button", { name: "Aria Agent" }));
const question = "How much have I spent this week?";
const suggestion = await screen.findByRole("button", { name: question });
await waitFor(() => expect(suggestion).toBeEnabled());
act(() => fireEvent.click(suggestion));
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue(question);
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus();
expect(requests.filter((r) => r.url.endsWith("/chat/completions"))).toHaveLength(0);
act(() => send(question + " Show a daily breakdown."));
expect(await screen.findByText("Here is your weekly spend.")).toBeInTheDocument();
for (const label of [question, "Which models are costing the most?", "How can I reduce my spend?"]) {
expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument();
@ -270,7 +313,7 @@ describe("Aria Agent in the gateway", () => {
expect(completions).toHaveLength(1);
expect(completions[0].body.messages).toEqual([
{ role: "system", content: expect.any(String) },
{ role: "user", content: question },
{ role: "user", content: question + " Show a daily breakdown." },
]);
if (role === "internal_user") {
expect(completions[0].body.tools).toEqual([
@ -285,7 +328,6 @@ describe("Aria Agent in the gateway", () => {
it("should disable suggested questions when no chat model is available", async () => {
const requests = gateway([], { models: [] });
renderWidget();
await userEvent.setup().click(await screen.findByRole("button", { name: "Aria Agent" }));
const picker = await screen.findByRole("combobox", { name: "Aria Agent model" });
expect(picker).toBeEnabled();
expect(screen.queryByText("No chat models are available for your account.")).not.toBeInTheDocument();
@ -302,201 +344,17 @@ describe("Aria Agent in the gateway", () => {
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
});
it("should restore header trigger focus and preserve the conversation when the popup closes with Escape", async () => {
const requests = gateway([answer("Your current usage is $12.34."), answer("Last month was $10.")]);
const user = userEvent.setup();
const backgroundAction = vi.fn();
function PageAction() {
return <button onClick={backgroundAction}>Background page action</button>;
}
renderWidget(PageAction);
await user.click(await screen.findByRole("button", { name: "Aria Agent" }));
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
expect(popup).toBeVisible();
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Background page action" }));
expect(backgroundAction).toHaveBeenCalledOnce();
expect(popup).toBeVisible();
act(() => send("What is my usage?"));
await screen.findByText("Your current usage is $12.34.");
act(() => fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And last month?" } }));
await user.click(screen.getByPlaceholderText("Ask Aria…"));
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument());
const trigger = screen.getByRole("button", { name: "Aria Agent" });
await waitFor(() => expect(trigger).toHaveFocus());
await user.keyboard("{Enter}");
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeVisible();
expect(screen.getByText("Your current usage is $12.34.")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And last month?");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
await screen.findByText("Last month was $10.");
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions.at(-1)?.body.messages).toEqual([
{ role: "system", content: expect.any(String) },
{ role: "user", content: "What is my usage?" },
{ role: "assistant", content: "Your current usage is $12.34." },
{ role: "user", content: "And last month?" },
]);
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveFocus());
});
it("should show the same product name on Home, Usage, the header trigger and the popup", async () => {
gateway([]);
const view = renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
expect(within(home).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
view.refresh(false, true);
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
expect(within(usage).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
expect(screen.getByRole("button", { name: "Open Aria Agent" })).toHaveTextContent("Aria Agent");
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
expect(within(popup).getByRole("heading", { name: "Aria Agent" })).toHaveTextContent("Aria Agent");
});
it.each([
[true, "SI"],
[false, "AI"],
])("should describe usage help in the configured terminology when SI terminology is %s", async (useSi, shownTerm) => {
session("internal_user");
gateway([], { useSiTerminology: useSi });
renderWidget(undefined, false, true, true);
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
expect(await within(usage).findByText(`Your ${shownTerm} usage assistant`)).toBeInTheDocument();
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
const popup = await screen.findByRole("dialog", { name: "Aria Agent" });
expect(within(popup).getByText(`Your ${shownTerm} assistant for usage and spend.`)).toBeInTheDocument();
});
it("should keep the same scoped conversation when leaving Usage and reopening the header trigger", async () => {
session("internal_user");
const requests = gateway(
[answer("Your usage is within budget."), answer("The same usage conversation continues.")],
{
models: [
{ model_group: "chat-model", mode: "chat" },
{ model_group: "other-chat-model", mode: "chat" },
],
},
);
const view = renderWidget(undefined, false, true);
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
await selectModel("other-chat-model");
act(() => {
fireEvent.change(within(usage).getByRole("textbox", { name: "Message Aria Agent" }), {
target: { value: "Check my usage" },
});
});
view.refresh(false, false);
expect(screen.queryByRole("region", { name: "Ask Aria about usage" })).not.toBeInTheDocument();
const trigger = screen.getByRole("button", { name: "Aria Agent" });
expect(trigger).toHaveTextContent("Aria Agent");
act(() => trigger.focus());
await userEvent.setup().keyboard("{Enter}");
expect(await screen.findByPlaceholderText("Ask Aria…")).toHaveValue("Check my usage");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
await screen.findByText("Your usage is within budget.");
act(() => {
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And last month?" } });
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
});
view.refresh(false, true);
const restoredUsage = await screen.findByRole("region", { name: "Ask Aria about usage" });
expect(within(restoredUsage).getByRole("textbox", { name: "Message Aria Agent" })).toHaveValue("And last month?");
view.refresh(false, false);
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(screen.getByText("Your usage is within budget.")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And last month?");
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
await screen.findByText("The same usage conversation continues.");
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions.map((request) => request.body.model)).toEqual(["other-chat-model", "other-chat-model"]);
expect(completions.at(-1)?.body.messages).toEqual([
{ role: "system", content: expect.any(String) },
{ role: "user", content: "Check my usage" },
{ role: "assistant", content: "Your usage is within budget.", tool_calls: undefined },
{ role: "user", content: "And last month?" },
]);
expect(completions.at(-1)?.body.tools).toEqual([
expect.objectContaining({ function: expect.objectContaining({ name: "render_usage_chart" }) }),
expect.objectContaining({ function: expect.objectContaining({ name: "get_usage_data" }) }),
]);
});
it("should keep the header trigger unavailable without a signed-in session", async () => {
it("keeps Aria unavailable without a signed-in session", async () => {
document.cookie = "token=; Max-Age=0; Path=/";
const requests = gateway([]);
const { client } = renderWidget();
await screen.findByText("Session ready");
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
act(() => fireEvent.keyDown(document, { key: "j", ctrlKey: true }));
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
});
it("should share the usage prompt's draft, selected model and conversation with the header panel", async () => {
const requests = gateway([answer("Your spend is within budget."), answer("The same conversation continues.")], {
models: [
{ model_group: "chat-model", mode: "chat" },
{ model_group: "other-chat-model", mode: "chat" },
],
});
renderWidget(undefined, false, true);
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
await selectModel("other-chat-model");
act(() => {
fireEvent.change(within(usage).getByRole("textbox", { name: "Message Aria Agent" }), {
target: { value: "How is my spend?" },
});
fireEvent.click(screen.getByRole("button", { name: "Open Aria Agent" }));
});
expect(usage).not.toBeVisible();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("How is my spend?");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
act(() => fireEvent.click(screen.getByRole("button", { name: "Send message" })));
expect(await screen.findByText("Your spend is within budget.")).toBeInTheDocument();
act(() => {
fireEvent.change(screen.getByPlaceholderText("Ask Aria…"), { target: { value: "And my teams?" } });
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
});
const usageTrigger = screen.getByRole("button", { name: "Open Aria Agent" });
expect(usageTrigger).toHaveAttribute("aria-expanded", "false");
expect(within(usage).getByRole("textbox", { name: "Message Aria Agent" })).toHaveValue("And my teams?");
expect(within(usage).getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
const headerTrigger = screen.getByRole("button", { name: "Aria Agent" });
act(() => fireEvent.click(headerTrigger));
expect(usageTrigger).toHaveAttribute("aria-expanded", "true");
expect(headerTrigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(screen.getByText("Your spend is within budget.")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("And my teams?");
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
});
expect(await screen.findByText("The same conversation continues.")).toBeInTheDocument();
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions.map((request) => request.body.model)).toEqual(["other-chat-model", "other-chat-model"]);
expect(requests.some((request) => request.url.includes("/usage/ai/chat"))).toBe(false);
expect(completions.at(-1)?.body.messages).toEqual([
{ role: "system", content: expect.any(String) },
{ role: "user", content: "How is my spend?" },
{ role: "assistant", content: "Your spend is within budget." },
{ role: "user", content: "And my teams?" },
]);
});
it.each([
["proxy_admin", true, "global", true],
["proxy_admin", false, "global", false],
@ -512,24 +370,33 @@ describe("Aria Agent in the gateway", () => {
const args = scope === "global" ? { ...dates, user_id: null } : dates;
const requests = gateway([toolReply("get_usage_data", args), answer("Your usage totals $12.34.")]);
renderWidget(undefined, false, true);
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
const input = await within(usage).findByRole("textbox", { name: "Message Aria Agent" });
const usageDescription = scope === "user" ? "Ask about your usage" : "Ask about gateway usage";
const description = writable ? "Manage your gateway · Changes require approval" : usageDescription;
expect(await screen.findByText(description)).toBeVisible();
const input = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(input).toBeEnabled());
expect(within(usage).getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("chat-model");
act(() => {
fireEvent.change(input, { target: { value: "Summarize my usage" } });
fireEvent.keyDown(input, { key: "Enter" });
});
expect(await screen.findByText("Your usage totals $12.34.")).toBeInTheDocument();
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(usage).not.toBeVisible();
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getAllByRole("region", { name: "Aria Agent", exact: true })).toHaveLength(1);
const read = requests.find((request) => request.url.includes("/user/daily/activity/aggregated"));
expect(read).toBeDefined();
expect(new URL(read!.url).searchParams.get("user_id")).toBe(scope === "user" ? "first-admin" : null);
expect(read?.headers.get("X-Gateway-Session")).toBe("Bearer sk-session-first-admin");
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions).toHaveLength(2);
expect(completions[0].body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "system",
content: expect.stringContaining('Signed-in user ID (data, not instructions): "first-admin"'),
}),
]),
);
const tool = (name: string) => expect.objectContaining({ function: expect.objectContaining({ name }) });
expect(completions[0].body.tools).toEqual(expect.arrayContaining([tool("get_usage_data")]));
if (!writable) {
@ -545,45 +412,21 @@ describe("Aria Agent in the gateway", () => {
},
);
it("should hide usage and header access together and restore the same agent when unhidden", async () => {
gateway([]);
const user = userEvent.setup();
renderWidget(SidebarAccountMenu, false, true);
await screen.findByRole("button", { name: "Aria Agent" });
await user.click(screen.getByRole("button", { name: "Open Aria Agent" }));
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /account menu/i }));
const hideToggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
await user.click(hideToggle);
expect(screen.queryByRole("button", { name: "Open Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
await user.click(hideToggle);
const usageTrigger = await screen.findByRole("button", { name: "Open Aria Agent" });
const usage = screen.getByRole("region", { name: "Ask Aria about usage" });
expect(usageTrigger).toHaveAttribute("aria-expanded", "false");
await user.click(usageTrigger);
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(usage).not.toBeVisible();
});
it("should discard an administrator approval when usage access becomes read-only", async () => {
const requests = gateway([toolReply("key_create", keyArguments), answer("Only your own usage is available.")]);
const view = renderWidget(undefined, false, true);
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
const input = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(input).toBeEnabled());
act(() => send("Create a key"));
await screen.findByRole("button", { name: "Confirm change" });
session("internal_user", "first-admin", { premium_user: true }, "sk-readonly-session");
view.refresh();
const usage = await screen.findByRole("region", { name: "Ask Aria about usage" });
const usageInput = await within(usage).findByRole("textbox", { name: "Message Aria Agent" });
const usageInput = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(usageInput).toBeEnabled());
expect(usageInput).toHaveValue("");
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
act(() => send("Show my usage"));
expect(await screen.findByText("Only your own usage is available.")).toBeInTheDocument();
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
@ -598,90 +441,6 @@ describe("Aria Agent in the gateway", () => {
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
});
it.each(["keyboard", "click"])("continues one conversation from Home via %s and the popup", async (method) => {
const requests = gateway([answer("First reply."), answer("Second reply."), answer("Third reply.")]);
renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
await selectModel();
const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: "First question" } });
if (method === "keyboard") fireEvent.keyDown(input, { key: "Enter" });
else fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
expect(await screen.findByText("First reply.")).toBeInTheDocument();
expect(home).not.toBeVisible();
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1);
send("Second question");
expect(await screen.findByText("Second reply.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
expect(home).toBeVisible();
send("Third question");
expect(await screen.findByText("Third reply.")).toBeInTheDocument();
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions).toHaveLength(3);
expect(completions[2].body.messages).toEqual([
{ role: "system", content: expect.any(String) },
{ role: "user", content: "First question" },
{ role: "assistant", content: "First reply." },
{ role: "user", content: "Second question" },
{ role: "assistant", content: "Second reply." },
{ role: "user", content: "Third question" },
]);
});
it("prefills each Home suggestion without sending until the edited draft is submitted", async () => {
const requests = gateway([answer("Budget details received.")]);
renderWidget(undefined, true);
await selectModel();
const input = screen.getByRole("textbox", { name: "Message Aria Agent" });
for (const label of ["Create a key", "Add a model", "Set a budget"]) {
fireEvent.click(screen.getByRole("button", { name: label, exact: true }));
expect(input).toHaveValue(label);
expect(input).toHaveFocus();
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
}
fireEvent.change(input, { target: { value: "Set a budget of $10 for my team" } });
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
fireEvent.keyDown(input, { key: "Enter" });
expect(await screen.findByText("Budget details received.")).toBeInTheDocument();
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions).toHaveLength(1);
expect(completions[0].body.model).toBe("chat-model");
expect(completions[0].body.messages).toContainEqual({ role: "user", content: "Set a budget of $10 for my team" });
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
});
it("should preserve the draft and chosen model when opening, closing and leaving Home", async () => {
const requests = gateway([answer("Draft received.")], {
models: [
{ model_group: "claude-sonnet-5-5", mode: "chat" },
{ model_group: "chat-model", mode: "chat" },
],
});
const view = renderWidget(undefined, true);
await selectModel();
fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
target: { value: "Keep this draft" },
});
fireEvent.click(screen.getByRole("button", { name: "Open conversation" }));
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("Keep this draft");
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
const home = screen.getByRole("region", { name: "Ask Aria Agent" });
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Keep this draft");
view.refresh(false);
expect(home).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Aria Agent" }));
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("Keep this draft");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(await screen.findByText("Draft received.")).toBeInTheDocument();
const completion = requests.find((request) => request.url.endsWith("/chat/completions"));
expect(completion?.body.model).toBe("chat-model");
expect(completion?.body.messages).toContainEqual({ role: "user", content: "Keep this draft" });
});
it("should start over with an empty history and draft while preserving the selected model", async () => {
const requests = gateway([answer("Old answer."), answer("Fresh answer.")], {
models: [
@ -701,7 +460,7 @@ describe("Aria Agent in the gateway", () => {
expect(screen.queryByText("Old answer.")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
expect(screen.getByRole("combobox", { name: "Aria Agent model" })).toHaveValue("other-chat-model");
expect(screen.getByRole("button", { name: "Start over" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Start over" })).not.toBeInTheDocument();
act(() => send("Fresh question"));
await screen.findByText("Fresh answer.");
const completion = requests.filter((request) => request.url.endsWith("/chat/completions")).at(-1);
@ -714,14 +473,13 @@ describe("Aria Agent in the gateway", () => {
it("should focus a pending approval when reopening the same conversation", async () => {
const requests = gateway([toolReply("key_create", keyArguments)]);
renderWidget();
const view = renderWidget();
await openWidget();
act(() => send("Prepare a key for approval"));
const review = await screen.findByRole("region", { name: "Create a virtual key" });
expect(review).toHaveFocus();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
await user.click(screen.getByRole("button", { name: "Aria Agent" }));
view.refresh(false, false, false);
view.refresh(false, false, true);
await waitFor(() => expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveFocus());
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
});
@ -768,65 +526,6 @@ describe("Aria Agent in the gateway", () => {
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
});
it("keeps Home drafts unsent without a model or with empty or oversized input", async () => {
const options: GatewayOptions = { models: [] };
const requests = gateway([], options);
const { client } = renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
const user = userEvent.setup();
await user.click(await within(home).findByRole("combobox", { name: "Aria Agent model" }));
expect(await screen.findByText("No available models")).toBeVisible();
await user.keyboard("{Escape}");
expect(within(home).getByPlaceholderText("What would you like to do?")).toBeDisabled();
expect(within(home).getByRole("button", { name: "Send message" })).toBeDisabled();
options.models = [{ model_group: "chat-model", mode: "chat" }];
await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] }));
await selectModel();
for (const text of [" ", "x".repeat(MAX_INPUT_LENGTH + 1)]) {
const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: text } });
fireEvent.keyDown(input, { key: "Enter" });
const submit = within(home).getByRole("button", { name: "Send message" });
expect(submit).toBeDisabled();
fireEvent.click(submit);
expect(input).toHaveValue(text);
}
expect(home).toBeVisible();
expect(within(home).getByRole("alert")).toHaveTextContent("8,000 characters");
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
});
it("shows destination consent in Home and discards its draft when the destination changes", async () => {
const settings = { target: EXTERNAL_INFERENCE, status: 200 };
const settingsReady = deferred<void>();
const requests = gateway([], {
settings: async () => {
await settingsReady.promise;
return json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: settings.target });
},
});
const { client } = renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
expect(await within(home).findByLabelText("Loading gateway settings")).toBeInTheDocument();
await act(async () => settingsReady.resolve());
const consent = await within(home).findByRole("button", { name: "Use configured gateway" });
expect(within(home).getByText(EXTERNAL_INFERENCE)).toBeInTheDocument();
expect(within(home).queryByPlaceholderText("What would you like to do?")).not.toBeInTheDocument();
fireEvent.click(consent);
await selectModel();
fireEvent.change(within(home).getByPlaceholderText("What would you like to do?"), {
target: { value: "Old gateway draft" },
});
settings.target = `${EXTERNAL_INFERENCE}/changed`;
await act(async () => client.invalidateQueries({ queryKey: ["proxySettings"] }));
fireEvent.click(await within(home).findByRole("button", { name: "Use configured gateway" }));
await selectModel();
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("");
expect(home).toBeVisible();
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
});
it.each([
["sidebar", SidebarAccountMenu],
["navbar", UserDropdown],
@ -834,27 +533,27 @@ describe("Aria Agent in the gateway", () => {
gateway([]);
const user = userEvent.setup();
const view = renderWidget(Menu, true);
await screen.findByRole("button", { name: "Aria Agent" });
await screen.findByRole("link", { name: "Aria Agent" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
const toggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
view.unmount();
const restored = renderWidget(Menu, true);
await screen.findByText("Session ready");
await waitFor(() => expect(restored.client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /account menu/i }));
const savedToggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
expect(savedToggle).toBeChecked();
await user.click(savedToggle);
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
expect(await screen.findByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Aria Agent" })).toBeVisible();
expect(await screen.findByRole("region", { name: "Aria Agent", exact: true })).toBeVisible();
for (const premiumUser of [false, null, undefined, "true"]) {
session("proxy_admin", "first-admin", { premium_user: premiumUser });
@ -864,27 +563,7 @@ describe("Aria Agent in the gateway", () => {
}
});
it.each([false, null, undefined, "true"])(
"should offer the scoped header trigger without admin Home access when premium_user is %s",
async (premiumUser) => {
session("proxy_admin", "first-admin", { premium_user: premiumUser });
const requests = gateway([]);
const { client } = renderWidget(undefined, true);
await screen.findByText("Session ready");
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
act(() => fireEvent.keyDown(document, { key: "j", metaKey: true }));
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "true");
act(() => fireEvent.keyDown(document, { key: "j", ctrlKey: true }));
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
},
);
it("should discard admin state and preserve scoped header trigger access when enterprise access is lost", async () => {
it("discards admin state and preserves scoped page access when enterprise access is lost", async () => {
const requests = gateway([answer("Your usage remains available.")]);
const view = renderWidget();
await openWidget();
@ -892,9 +571,6 @@ describe("Aria Agent in the gateway", () => {
await waitFor(() => expect(view.client.isFetching()).toBe(0));
session("proxy_admin", "first-admin", { premium_user: false });
view.refresh();
expect(screen.getByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
act(() => send("Show my usage"));
@ -908,10 +584,8 @@ describe("Aria Agent in the gateway", () => {
session();
view.refresh();
expect(await screen.findByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
expect(await screen.findByRole("region", { name: "Aria Agent", exact: true })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
});
@ -919,31 +593,31 @@ describe("Aria Agent in the gateway", () => {
gateway([]);
const user = userEvent.setup();
const view = renderWidget(SidebarAccountMenu);
await screen.findByRole("button", { name: "Aria Agent" });
await screen.findByRole("link", { name: "Aria Agent" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
await user.click(await screen.findByRole("switch", { name: "Toggle hide Aria Agent" }));
session("proxy_admin", "second-admin");
view.refresh();
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
session();
view.refresh();
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
switchToWorkerUrl("https://other-gateway.test");
view.refresh();
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
switchToWorkerUrl(MANAGEMENT);
view.refresh();
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument();
act(() => {
localStorage.clear();
window.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(await screen.findByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeChecked();
});
@ -961,79 +635,16 @@ describe("Aria Agent in the gateway", () => {
expect(screen.queryByRole("switch", { name: "Toggle hide Aria Agent" })).not.toBeInTheDocument();
});
it("keeps the page mounted and comes back closed after Hide Aria Agent is turned off", async () => {
gateway([]);
pageMounts = 0;
const user = userEvent.setup();
renderWidget(SidebarAccountMenu);
await screen.findByRole("button", { name: "Aria Agent" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
const toggle = await screen.findByRole("switch", { name: "Toggle hide Aria Agent" });
act(() => fireEvent.keyDown(document, { key: "j", metaKey: true }));
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
await user.click(toggle);
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
await user.click(toggle);
expect(await screen.findByRole("button", { name: "Aria Agent" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(pageMounts).toBe(1);
});
it("moves focus into the panel on open: the panel while loading, the message box, then a pending review", async () => {
const settings = deferred<Response>();
gateway([toolReply("key_create", keyArguments)], { settings: () => settings.promise });
renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
await waitFor(() => expect(screen.getByRole("dialog", { name: "Aria Agent" })).toHaveFocus());
await act(async () =>
settings.resolve(json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: INFERENCE })),
);
await selectModel();
const user = userEvent.setup();
const reopen = async () => {
await user.click(screen.getByRole("button", { name: "Close Aria Agent" }));
await user.click(screen.getByRole("button", { name: "Aria Agent" }));
};
await reopen();
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toHaveFocus());
send("Create a key for the team");
const review = await screen.findByRole("region", { name: "Create a virtual key" });
await reopen();
await waitFor(() => expect(review).toHaveFocus());
});
it("toggles with Cmd+J or Ctrl+J and ignores the shortcut while hidden", async () => {
gateway([]);
const user = userEvent.setup();
renderWidget(SidebarAccountMenu);
const headerTrigger = await screen.findByRole("button", { name: "Aria Agent" });
fireEvent.keyDown(document, { key: "j", metaKey: true, shiftKey: true });
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "j", metaKey: true });
expect(await screen.findByRole("dialog", { name: "Aria Agent" })).toBeInTheDocument();
expect(headerTrigger).toHaveAttribute("aria-expanded", "true");
fireEvent.keyDown(document, { key: "j", ctrlKey: true });
expect(screen.queryByRole("dialog", { name: "Aria Agent" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /account menu/i }));
await user.click(await screen.findByRole("switch", { name: "Toggle hide Aria Agent" }));
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "j", metaKey: true });
expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Aria Agent" })).not.toBeInTheDocument();
});
it.each(["proxy_admin_viewer", "internal_user", "internal_user_viewer", "org_admin"])(
"should expose only scoped usage tools from the dashboard header trigger to %s",
"exposes only scoped usage tools on the Aria page to %s",
async (role) => {
session(role);
const requests = gateway([answer("Usage is available.")]);
const { client } = renderWidget(undefined, true);
await screen.findByText("Session ready");
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(screen.getByRole("button", { name: "Aria Agent" })).toBeVisible();
expect(screen.getByRole("link", { name: "Aria Agent" })).toBeVisible();
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
act(() => fireEvent.click(screen.getByRole("button", { name: "Aria Agent" })));
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
act(() => send("Show my usage"));
await screen.findByText("Usage is available.");
@ -1051,7 +662,6 @@ describe("Aria Agent in the gateway", () => {
it("uses the chosen model and existing session header for reads", async () => {
const requests = gateway([toolReply("team_info", { team_id: "team-1" }), answer("The team budget is $40.")]);
renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
await selectModel();
send("Check the team budget");
@ -1120,7 +730,7 @@ describe("Aria Agent in the gateway", () => {
},
);
renderWidget(undefined, false, true);
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
const input = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(input).toBeEnabled());
await selectModel("other-chat-model");
act(() => send("Which models cost the most?"));
@ -1168,7 +778,7 @@ describe("Aria Agent in the gateway", () => {
].join("\n");
gateway([answer(markdown), answer("The primary model accounts for $3.40 of your $4.00 spend.")]);
renderWidget(undefined, false, true);
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
const input = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(input).toBeEnabled());
act(() => send("Show my usage by model as a table"));
const conversation = screen.getByLabelText("Aria Agent conversation");
@ -1211,7 +821,6 @@ describe("Aria Agent in the gateway", () => {
],
});
const { client } = renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
send("Use the default");
await screen.findByText("Default selected.");
@ -1240,7 +849,7 @@ describe("Aria Agent in the gateway", () => {
const requests = gateway([answer("Your usage is available.")], options);
const { client } = renderWidget(undefined, false, true);
const user = userEvent.setup();
const input = await screen.findByRole("textbox", { name: "Message Aria Agent" });
const input = await screen.findByPlaceholderText("Ask Aria…");
await waitFor(() => expect(input).toBeEnabled());
const picker = screen.getByRole("combobox", { name: "Aria Agent model" });
expect(picker).toHaveValue("allowed-chat");
@ -1269,7 +878,7 @@ describe("Aria Agent in the gateway", () => {
const requests = gateway([], { callableModels: async () => json({ error: { message: "Forbidden" } }, 403) });
renderWidget(undefined, false, true);
expect(await screen.findByText(/Could not load models/)).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message Aria Agent" })).toBeDisabled();
expect(screen.getByPlaceholderText("Ask Aria…")).toBeDisabled();
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
});
@ -1289,7 +898,7 @@ describe("Aria Agent in the gateway", () => {
}),
});
renderWidget(undefined, false, true);
await waitFor(() => expect(screen.getByRole("textbox", { name: "Message Aria Agent" })).toBeEnabled());
await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled());
const picker = screen.getByRole("combobox", { name: "Aria Agent model" });
expect(picker).toHaveValue("team-chat");
const user = userEvent.setup();
@ -1327,21 +936,21 @@ describe("Aria Agent in the gateway", () => {
]);
});
it("should keep a single inline action through review, close/reopen and one confirmed write", async () => {
it("keeps one action across route navigation and one confirmed write", async () => {
const response = deferred<Response>();
const proposed = { ...toolReply("key_create", keyArguments), content: "Here is the requested change." };
const requests = gateway([proposed, answer("Created the key."), answer("You are welcome.")], {
write: () => response.promise,
});
renderWidget();
const view = renderWidget();
await openWidget();
send("Create a key for the team");
const review = await screen.findByRole("region", { name: "Create a virtual key" });
expect(screen.getAllByRole("dialog", { name: "Aria Agent" })).toHaveLength(1);
expect(screen.getAllByRole("region", { name: "Aria Agent", exact: true })).toHaveLength(1);
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
expect(within(review).getByText("Widget key")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
fireEvent.click(screen.getByRole("button", { name: "Aria Agent" }));
view.refresh(false, false, false);
view.refresh(false, false, true);
const confirm = await screen.findByRole("button", { name: "Confirm change" });
fireEvent.click(confirm);
fireEvent.click(confirm);
@ -1349,9 +958,9 @@ describe("Aria Agent in the gateway", () => {
act(() => fireEvent.click(screen.getByRole("button", { name: "Start over" })));
expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveTextContent("Applying");
expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close Aria Agent" }));
view.refresh(false, false, false);
view.refresh(false, false, true);
await act(async () => response.resolve(json({ key: NEW_KEY })));
fireEvent.click(screen.getByRole("button", { name: "Aria Agent" }));
expect(await screen.findByText("Created the key.")).toBeInTheDocument();
expect(screen.getByText(NEW_KEY)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy generated key" })).toBeInTheDocument();
@ -1473,21 +1082,18 @@ describe("Aria Agent in the gateway", () => {
} else {
session("proxy_admin_viewer", "first-admin", { premium_user: true }, "sk-session-first-admin-viewer");
view.refresh();
expect(screen.queryByRole("button", { name: "Aria Agent" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument();
expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeVisible();
session();
view.refresh();
}
if (stage === "pending") await waitFor(() => expect(original?.signal.aborted).toBe(true));
const trigger = await screen.findByRole("button", { name: "Aria Agent" });
if (trigger.getAttribute("aria-expanded") === "false") act(() => fireEvent.click(trigger));
await screen.findByRole("dialog", { name: "Aria Agent" });
await screen.findByRole("region", { name: "Aria Agent", exact: true });
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
expect(screen.queryByText("Old question")).not.toBeInTheDocument();
expect(screen.queryByText("Old answer.")).not.toBeInTheDocument();
expect(screen.queryByText("Old approval")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue("");
expect(await screen.findByPlaceholderText("Ask Aria…")).toHaveValue("");
await selectModel();
if (stage === "pending") await act(async () => pending.resolve(toolReply("key_create", keyArguments)));
expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
@ -1531,7 +1137,6 @@ describe("Aria Agent in the gateway", () => {
const settings = { target: EXTERNAL_INFERENCE, status: 200 };
const requests = gateway([answer("Connected.")], { settings });
const { client } = renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
const approve = await screen.findByRole("button", { name: "Use configured gateway" });
expect(requests.some((request) => request.url.startsWith(EXTERNAL_INFERENCE))).toBe(false);
fireEvent.click(approve);
@ -1550,7 +1155,7 @@ describe("Aria Agent in the gateway", () => {
it("blocks inference when settings cannot be loaded", async () => {
const requests = gateway([], { settings: { target: INFERENCE, status: 503 } });
renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask Aria Agent" });
const home = await screen.findByRole("region", { name: "Aria Agent", exact: true });
expect(await within(home).findByText("Could not load gateway settings.")).toBeInTheDocument();
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
});
@ -1570,7 +1175,6 @@ describe("Aria Agent in the gateway", () => {
},
});
const view = renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
fireEvent.click(await screen.findByRole("button", { name: "Use configured gateway" }));
await selectModel();
send("Check the original gateway");
@ -1580,7 +1184,6 @@ describe("Aria Agent in the gateway", () => {
switchToWorkerUrl(worker);
view.refresh();
});
fireEvent.click(await screen.findByRole("button", { name: "Aria Agent" }));
await settingsRequested.promise;
expect(screen.queryByText(EXTERNAL_INFERENCE)).not.toBeInTheDocument();
expect(await screen.findByLabelText("Loading gateway settings")).toBeInTheDocument();
@ -1620,7 +1223,7 @@ describe("Aria Agent in the gateway", () => {
expect(screen.getByPlaceholderText("Ask Aria…")).toHaveValue(draft);
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
expect(screen.getByRole("alert")).toHaveTextContent("8,000 characters");
expect(within(screen.getByLabelText("Aria Agent conversation")).queryByText(draft)).not.toBeInTheDocument();
expect(screen.queryByLabelText("Aria Agent conversation")).not.toBeInTheDocument();
});
it("retains the action receipt across later model and settings failures", async () => {

View file

@ -13,7 +13,11 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { History, RotateCcw, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { uiHref } from "@/utils/uiHref";
import { sidebarMenuButtonVariants } from "@/components/shared/Sidebar";
import { RotateCcw } from "lucide-react";
import { z } from "zod";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
@ -25,40 +29,27 @@ import { createGatewayClient } from "@/components/llm_calls/gateway_client";
import { getProxyBaseUrl } from "@/components/networking";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { FieldError } from "@/components/ui/field";
import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { useTerminology } from "@/contexts/TerminologyContext";
import { cn } from "@/lib/cva.config";
import { hasProxyWideSpendView, isProxyAdminRole } from "@/utils/roles";
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
import { AriaAvatar } from "./AriaAvatar";
import { AriaStart } from "./AriaStart";
import { ARIA_AGENT_LABEL } from "./aria-brand";
import { LiteAdminConversation } from "./LiteAdminConversation";
import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
type ManagementSession = Omit<LiteAdminSession, "inferenceBaseUrl">;
type LiteAdminState = {
open: boolean;
toggle: () => void;
show: () => void;
access: LiteAdminSession["access"];
triggerRef: RefObject<HTMLButtonElement | null>;
homeContainer: HTMLDivElement | null;
setHomeContainer: (container: HTMLDivElement | null) => void;
usageContainer: HTMLDivElement | null;
setUsageContainer: (container: HTMLDivElement | null) => void;
pageContainer: HTMLDivElement | null;
setPageContainer: (container: HTMLDivElement | null) => void;
};
function getComposerContainer(state: LiteAdminState | null, open: boolean): HTMLDivElement | null {
if (open) return null;
return state?.usageContainer ?? state?.homeContainer ?? null;
}
const LiteAdminContext = createContext<LiteAdminState | null>(null);
function useLiteAdminSession() {
const auth = useAuthorized();
@ -73,7 +64,13 @@ function useLiteAdminSession() {
mode: "usage",
scope: hasProxyWideSpendView(auth.userRole) ? { type: "global" } : { type: "user", userId: auth.userId },
};
const session = { token: auth.token, accessToken: auth.accessToken, managementBaseUrl: getProxyBaseUrl(), access };
const session = {
userId: auth.userId,
token: auth.token,
accessToken: auth.accessToken,
managementBaseUrl: getProxyBaseUrl(),
access,
};
return {
session,
key: JSON.stringify([auth.userId, session.token, session.accessToken, session.managementBaseUrl, access]),
@ -83,151 +80,93 @@ function useLiteAdminSession() {
export function LiteAdminFrame({ children }: { children: ReactNode }) {
const configured = useLiteAdminSession();
const sessionKey = configured?.key ?? null;
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const router = useRouter();
const [started, setStarted] = useState(false);
const [homeContainer, setHomeContainer] = useState<HTMLDivElement | null>(null);
const [usageContainer, updateUsageContainer] = useState<HTMLDivElement | null>(null);
const setUsageContainer = useCallback((container: HTMLDivElement | null) => {
updateUsageContainer(container);
const [pageContainer, updatePageContainer] = useState<HTMLDivElement | null>(null);
const setPageContainer = useCallback((container: HTMLDivElement | null) => {
updatePageContainer(container);
if (container) setStarted(true);
}, []);
const [openedSession, setOpenedSession] = useState(sessionKey);
if (openedSession !== sessionKey) {
setOpenedSession(sessionKey);
setOpen(false);
setStarted(Boolean(usageContainer));
setStarted(Boolean(pageContainer));
}
const toggle = () => {
setStarted(true);
setOpen((current) => !current);
};
const show = () => {
if (open) return;
setStarted(true);
setOpen(true);
};
const showPanel = configured?.session.access.mode === "admin" || started;
useEffect(() => {
if (sessionKey === null) return;
const onKeyDown = (event: KeyboardEvent) => {
const modifier = event.metaKey || event.ctrlKey;
const extraModifier = event.shiftKey || event.altKey;
if (event.key.toLowerCase() !== "j" || !modifier || extraModifier) return;
if (event.key.toLowerCase() !== "j" || !(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey)
return;
event.preventDefault();
toggle();
if (pageContainer) {
pageContainer.querySelector<HTMLTextAreaElement>("textarea:enabled")?.focus();
} else {
router.push(uiHref("aria"));
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [sessionKey]);
}, [sessionKey, pageContainer, router]);
return (
<LiteAdminContext.Provider
value={
configured
? {
open,
toggle,
show,
access: configured.session.access,
triggerRef,
homeContainer,
setHomeContainer,
usageContainer,
setUsageContainer,
pageContainer,
setPageContainer,
}
: null
}
>
{children}
{configured && showPanel && (
<AriaPopup key={configured.key} session={configured.session} open={open} close={() => setOpen(false)} />
{configured && started && (
<AriaSession key={configured.key} session={configured.session} open={Boolean(pageContainer)} />
)}
</LiteAdminContext.Provider>
);
}
export function LiteAdminHome() {
export function AriaSidebarLink({ active }: { active: boolean }) {
const state = useContext(LiteAdminContext);
if (!state || state.access.mode !== "admin") return null;
const { open, show, setHomeContainer } = state;
if (!state) return null;
return (
<section aria-label={`Ask ${ARIA_AGENT_LABEL}`} hidden={open} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 className="flex items-center gap-2 text-sm font-semibold">
<AriaAvatar size={32} />
{ARIA_AGENT_LABEL}
</h2>
<p className="border-l pl-3 text-sm text-muted-foreground">Your gateway, a little easier.</p>
</div>
<Button variant="ghost" size="sm" onClick={show} className="text-muted-foreground">
<History className="size-3.5" />
Open conversation
</Button>
</div>
<div ref={setHomeContainer} />
</section>
<Link
href={uiHref("aria")}
aria-current={active ? "page" : undefined}
aria-label={ARIA_AGENT_LABEL}
title={ARIA_AGENT_LABEL}
className={cn(
sidebarMenuButtonVariants({ isActive: active }),
"group-data-[collapsed=true]/sidebar:justify-center",
)}
>
<AriaAvatar size={18} />
<span className="flex-1 group-data-[collapsed=true]/sidebar:hidden">{ARIA_AGENT_LABEL}</span>
<Kbd className="bg-transparent text-[10px] group-data-[collapsed=true]/sidebar:hidden">⌘J</Kbd>
</Link>
);
}
export default function LiteAdminTrigger() {
const state = useContext(LiteAdminContext);
if (!state) return null;
const { triggerRef, open, toggle } = state;
return (
<Button
variant="ghost"
ref={triggerRef}
aria-label={ARIA_AGENT_LABEL}
aria-keyshortcuts="Meta+J Control+J"
aria-expanded={open}
onClick={toggle}
className="gap-2 rounded-full border border-blue-200/70 bg-blue-50/80 pl-1.5 pr-3 text-blue-700 hover:bg-blue-100 hover:text-blue-800 aria-expanded:bg-blue-100 dark:border-blue-400/20 dark:bg-blue-400/10 dark:text-blue-300 dark:hover:bg-blue-400/20"
>
<AriaAvatar />
<span className="hidden lg:inline">{ARIA_AGENT_LABEL}</span>
<Kbd className="hidden lg:inline-flex">⌘J</Kbd>
</Button>
);
export function LiteAdminPage() {
const setPageContainer = useContext(LiteAdminContext)?.setPageContainer;
if (!setPageContainer)
return <p className="p-6 text-sm text-muted-foreground">Aria is not available for this session.</p>;
return <div ref={setPageContainer} className="flex min-h-0 flex-1 flex-col" />;
}
export function AriaUsageTrigger() {
const state = useContext(LiteAdminContext);
if (!state) return null;
return (
<Button
variant="outline"
size="sm"
aria-label={`Open ${ARIA_AGENT_LABEL}`}
aria-expanded={state.open}
onClick={state.show}
className="gap-1.5 border-blue-200 bg-blue-50/70 pl-1.5 text-blue-700 hover:bg-blue-100 hover:text-blue-800 dark:border-blue-400/20 dark:bg-blue-400/10 dark:text-blue-300 dark:hover:bg-blue-400/20"
>
<AriaAvatar size={24} />
{ARIA_AGENT_LABEL}
</Button>
<Link href={uiHref("aria")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<AriaAvatar size={18} />
Ask Aria
</Link>
);
}
export function AriaUsageComposer() {
const state = useContext(LiteAdminContext);
const term = useTerminology();
if (!state) return null;
const { open, setUsageContainer } = state;
return (
<section aria-label="Ask Aria about usage" hidden={open} className="mb-4">
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 className="flex items-center gap-2 text-sm font-medium">
<AriaAvatar size={24} /> {ARIA_AGENT_LABEL}
</h2>
<p className="text-sm text-muted-foreground">{term("Your AI usage assistant")}</p>
</div>
<div ref={setUsageContainer} />
</section>
);
}
function AriaPopup({ session, open, close }: { session: ManagementSession; open: boolean; close: () => void }) {
function AriaSession({ session, open }: { session: ManagementSession; open: boolean }) {
const settings = useProxySettingsQuery(session.accessToken);
const candidate =
settings.data?.LITELLM_UI_API_DOC_BASE_URL?.trim() ||
@ -244,7 +183,6 @@ function AriaPopup({ session, open, close }: { session: ManagementSession; open:
loading={settings.isPending}
retry={() => void settings.refetch()}
open={open}
close={close}
/>
);
}
@ -255,23 +193,19 @@ function Destination({
loading,
retry,
open,
close,
}: {
session: ManagementSession;
target: ReturnType<typeof resolveInferenceTarget> | null;
loading: boolean;
retry: () => void;
open: boolean;
close: () => void;
}) {
const [approved, setApproved] = useState(false);
if (target?.baseUrl && (!target.requiresConsent || approved)) {
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} close={close} />;
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} />;
}
return (
<Panel
open={open}
close={close}
composer={
<div className="p-4">
{loading && <Skeleton className="h-24" aria-label="Loading gateway settings" />}
@ -308,49 +242,34 @@ function Destination({
);
}
/** On open, focus `initialFocus` when given, else the composer when it is usable, else the panel itself. */
function Panel({
open,
close,
initialFocus,
composer,
welcome,
empty = false,
children,
}: {
open: boolean;
close: () => void;
initialFocus?: RefObject<HTMLElement | null>;
composer: ReactNode;
welcome?: ReactNode;
empty?: boolean;
children: ReactNode;
}) {
const state = useContext(LiteAdminContext);
const homeContainer = getComposerContainer(state, open);
const ref = useRef<HTMLDivElement>(null);
return (
<Dialog
open={open}
modal={false}
disablePointerDismissal
onOpenChange={(nextOpen) => {
if (!nextOpen) close();
}}
const container = useContext(LiteAdminContext)?.pageContainer;
if (!container) return null;
return createPortal(
<section
aria-label={ARIA_AGENT_LABEL}
className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-2 sm:px-4"
>
<DialogContent
ref={ref}
keepMounted
showCloseButton={false}
overlayClassName="hidden"
hidden={!open}
aria-label={ARIA_AGENT_LABEL}
initialFocus={() =>
initialFocus?.current ?? ref.current?.querySelector<HTMLElement>("textarea:enabled") ?? ref.current
}
finalFocus={state?.triggerRef}
className="flex h-[min(48rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 shadow-xl sm:max-w-4xl"
>
{children}
{homeContainer ? createPortal(composer, homeContainer) : composer}
</DialogContent>
</Dialog>
{empty ? (
welcome
) : (
<>
{children}
{composer}
</>
)}
</section>,
container,
);
}
@ -362,13 +281,17 @@ function useForegroundReviewFocus(
const lastReviewId = useRef<string | null>(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<HTMLTextAreaElement>("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 (
<Panel open={open} close={close} initialFocus={reviewRef} composer={composer}>
<Panel
composer={composer}
empty={chat.entries.length === 0}
welcome={
<AriaStart
composer={composer}
onQuestion={selectQuestion}
disabled={questionsDisabled}
access={session.access}
/>
}
>
<PanelHeader>
<Button
variant="ghost"
size="sm"
disabled={chat.phase === "applying" || (chat.entries.length === 0 && !input)}
onClick={startOver}
className="text-muted-foreground"
>
<RotateCcw className="size-3.5" />
Start over
</Button>
{(chat.entries.length > 0 || input.length > 0) && (
<Button
variant="ghost"
size="sm"
disabled={chat.phase === "applying"}
onClick={startOver}
className="text-muted-foreground"
>
<RotateCcw className="size-3.5" />
Start over
</Button>
)}
</PanelHeader>
<LiteAdminConversation
entries={chat.entries}
thinking={chat.phase === "thinking"}
readOnly={session.access.mode === "usage"}
open={open}
reviewRef={reviewRef}
onAnswer={chat.answer}
onQuestion={send}
questionsDisabled={busy || models.isError || !selectedModel}
/>
</Panel>
);
@ -476,40 +410,32 @@ function LiteAdminComposer({
models,
model,
onModelChange,
home,
usage,
tooLong,
...props
}: Omit<ComponentProps<typeof LiteAdminHomeComposer>, "modelPicker" | "usage"> & {
}: Omit<ComponentProps<typeof ChatComposer>, "tools" | "placeholder"> & {
models: UseQueryResult<ModelGroup[]>;
model: string | null;
onModelChange: (value: string | null) => void;
home: boolean;
usage: boolean;
tooLong: boolean;
}) {
return (
<div className={cn("space-y-3", !home && "border-t p-3")}>
<div className="space-y-3 px-4 pt-2 pb-5 sm:px-6">
<ModelStatus models={models} />
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
{home ? (
<LiteAdminHomeComposer
{...props}
usage={usage}
modelPicker={
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
}
/>
) : (
<ChatComposer
{...props}
placeholder="Ask Aria…"
className="[&_[data-slot=input-group]]:min-h-0 [&_textarea]:min-h-12"
tools={
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
}
/>
)}
<ChatComposer
{...props}
placeholder="Ask Aria…"
className="[&>div>[data-slot=input-group]]:min-h-0 [&>div>[data-slot=input-group]]:rounded-2xl [&>div>[data-slot=input-group]]:border-foreground/15 [&>div>[data-slot=input-group]]:shadow-none [&>div>[data-slot=input-group]]:ring-0 dark:[&>div>[data-slot=input-group]]:bg-foreground/[0.03] [&_textarea]:min-h-20 [&_textarea]:px-5 [&_textarea]:pt-5 [&_textarea]:text-base [&_textarea]:placeholder:text-muted-foreground"
tools={
<ModelPicker
models={models}
value={model}
onChange={onModelChange}
disabled={Boolean(props.isLoading)}
compact
/>
}
/>
</div>
);
}
@ -558,27 +484,5 @@ function ModelStatus({ models }: { models: UseQueryResult<ModelGroup[]> }) {
}
function PanelHeader({ children }: { children?: ReactNode }) {
const state = useContext(LiteAdminContext);
const term = useTerminology();
return (
<div className="flex items-start justify-between gap-3 border-b p-4">
<div className="flex flex-col gap-1 text-sm">
<DialogTitle className="flex items-center gap-2 font-semibold">
<AriaAvatar size={32} />
{ARIA_AGENT_LABEL}
</DialogTitle>
<DialogDescription>
{state?.access.mode === "usage"
? term("Your AI assistant for usage and spend.")
: "Ask about your gateway. Review changes in chat."}
</DialogDescription>
</div>
<div className="flex shrink-0">
{children}
<DialogClose render={<Button variant="ghost" size="icon-sm" aria-label={`Close ${ARIA_AGENT_LABEL}`} />}>
<X className="size-4" />
</DialogClose>
</div>
</div>
);
return <div className="flex h-12 shrink-0 items-center justify-end px-4 sm:px-6">{children}</div>;
}

View file

@ -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<ActionEntry["status"], string> = {
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<HTMLDivElement | null>;
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 (
<MessageScroller.Provider autoScroll>
<MessageScroller.Root className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
@ -64,14 +44,11 @@ export function LiteAdminConversation({
(entry.message.role === "assistant" ? (
<AriaAnswer message={entry.message} chart={entry.chart} question={entry.question} />
) : (
<article aria-label="Your question" className="space-y-2">
<p className="text-right text-xs font-medium text-muted-foreground">You</p>
<ChatMessageContent
message={entry.message}
allowImages={false}
isLastMessage={false}
isStreaming={false}
/>
<article
aria-label="Your question"
className="ml-auto w-fit max-w-[90%] whitespace-pre-wrap break-words rounded-xl bg-muted px-4 py-3 text-sm leading-relaxed"
>
{entry.message.content}
</article>
))}
{entry.kind === "action" && (
@ -95,30 +72,6 @@ export function LiteAdminConversation({
)}
</MessageScroller.Content>
</MessageScroller.Viewport>
{entries.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center gap-4 overflow-y-auto p-5 text-center">
<div className="flex items-center gap-2 pt-2">
<AriaAvatar size={40} />
<p className="text-base font-semibold">Hi, I’m Aria.</p>
</div>
<div className="flex w-full flex-col gap-2" aria-label="Suggested questions">
{SUGGESTED_QUESTIONS.map((question) => (
<Button
key={question}
variant="outline"
className="h-auto min-h-10 justify-start whitespace-normal px-3 py-2 text-left font-normal"
disabled={questionsDisabled}
onClick={() => onQuestion(question)}
>
{question}
</Button>
))}
</div>
<p className="max-w-64 text-xs leading-relaxed text-muted-foreground">
{readOnly ? "Answers use usage data your account can access." : "You review every change before it runs."}
</p>
</div>
)}
<MessageScroller.Button
aria-label="Jump to latest"
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full shadow-sm data-[active=false]:pointer-events-none data-[active=false]:opacity-0"

View file

@ -1,100 +0,0 @@
import { useRef, type ReactNode } from "react";
import { ArrowUp, BarChart3, Box, CreditCard, KeyRound, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
import { ARIA_AGENT_LABEL } from "./aria-brand";
const suggestions = [
{ label: "Create a key", icon: KeyRound },
{ label: "Add a model", icon: Box },
{ label: "Set a budget", icon: CreditCard },
];
const usageSuggestions = [
{ label: "Summarize my usage", icon: BarChart3 },
{ label: "Compare models", icon: Box },
{ label: "Explain my costs", icon: CreditCard },
];
export function LiteAdminHomeComposer({
value,
onChange,
onSubmit,
onCancel,
disabled,
isLoading,
submitDisabled,
modelPicker,
usage = false,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel?: () => void;
disabled: boolean;
isLoading: boolean;
submitDisabled: boolean;
modelPicker: ReactNode;
usage?: boolean;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);
return (
<InputGroup className="h-auto flex-col rounded-xl border-border bg-card shadow-xs">
<InputGroupTextarea
ref={inputRef}
value={value}
onChange={(event) => 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]`}
/>
<div className="flex w-full flex-wrap items-center justify-between gap-3 px-4 pb-4">
<div className="flex flex-wrap gap-2">
{(usage ? usageSuggestions : suggestions).map(({ label, icon: Icon }) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-2 bg-muted/30 font-normal text-muted-foreground shadow-none"
onClick={() => {
onChange(label);
inputRef.current?.focus();
}}
>
<Icon className="size-3.5" />
{label}
</Button>
))}
</div>
<div className="ml-auto flex min-w-0 max-w-full items-center gap-2">
<div className="w-64 min-w-0 text-muted-foreground">{modelPicker}</div>
{isLoading && onCancel ? (
<Button type="button" size="icon-sm" aria-label="Stop request" onClick={onCancel}>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
type="button"
size="icon-sm"
aria-label="Send message"
disabled={submitDisabled || isLoading}
onClick={onSubmit}
className="rounded-lg bg-info text-info-foreground shadow-xs hover:bg-info/90"
>
<ArrowUp className="size-4" />
</Button>
)}
</div>
</div>
</InputGroup>
);
}

View file

@ -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",

View file

@ -38,6 +38,7 @@ function getAccessPrompt(access: AriaAccess): string {
}
export interface LiteAdminOptions extends Omit<OperationContext, "beforeTool"> {
userId: string;
model: string;
messages: readonly Pick<ChatMessage, "role" | "content">[];
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);

View file

@ -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;

View file

@ -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<NavbarProps> = ({
sidebarCollapsed = false,
onToggleSidebar,
deploymentHealth,
deploymentHealthLink,
}) => {
const baseUrl = getProxyBaseUrl();
const { logoUrl, logoUrlDark } = useTheme();
@ -157,7 +159,7 @@ const Navbar: React.FC<NavbarProps> = ({
<ThemeToggle />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
{deploymentHealth}
<NotificationsBell accessToken={accessToken} />
<NotificationsBell accessToken={accessToken} deploymentHealthLink={deploymentHealthLink} />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
<UserDropdown onLogout={handleLogout} />
</div>

View file

@ -54,10 +54,13 @@ function CommandDialog({
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<InputGroup className="h-8! rounded-lg! border-border bg-muted/50 shadow-none! focus-within:border-ring dark:bg-muted/50 *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
className={cn(
"min-w-0 w-full border-0 bg-transparent p-0 text-sm text-foreground shadow-none outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-transparent",
className,
)}
{...props}
/>
<InputGroupAddon>