mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(ui): keep worker controls compact on narrow headers
This commit is contained in:
parent
58120baf64
commit
cd5ad06a07
7 changed files with 60 additions and 59 deletions
|
|
@ -45,7 +45,16 @@ export function DashboardHeader({
|
|||
<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} />}
|
||||
{showWorkerSelector && (
|
||||
<>
|
||||
<div className="@min-[840px]:hidden">
|
||||
<WorkerDropdown compact onWorkerSwitch={handleWorkerSwitch} />
|
||||
</div>
|
||||
<div className="hidden @min-[840px]:block">
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DashboardCommunityLinks
|
||||
className={showWorkerSelector ? "hidden @min-[840px]:flex" : "hidden @min-[400px]:flex"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl
|
|||
);
|
||||
};
|
||||
|
||||
/** Keeps ignored warnings reachable when the header has no active alerts. */
|
||||
export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandle }) => {
|
||||
const { canView } = useDeploymentHealthAccess();
|
||||
if (!canView) return null;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -166,15 +133,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
<DropdownMenuTrigger
|
||||
aria-label={`Switch workspace, current: ${activeLabel}`}
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("max-w-[220px] gap-1.5 pl-1.5 pr-2", className)}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" className={cn("max-w-[220px] gap-1.5 px-3", className)} />
|
||||
}
|
||||
>
|
||||
<WorkspaceIcon workspace={activeIcon} />
|
||||
<span data-slot="workspace-label" className="truncate">
|
||||
{activeLabel}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -734,7 +734,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
</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 group-data-[collapsed=true]/sidebar:[&_[data-slot=workspace-chevron]]:hidden" />
|
||||
<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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue