fix(ui): keep worker controls compact on narrow headers

This commit is contained in:
moe-berri 2026-11-04 16:38:00 +08:00
parent 58120baf64
commit cd5ad06a07
7 changed files with 60 additions and 59 deletions

View file

@ -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"}
/>

View file

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

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

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

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