fix(ui): view-switcher navigation from chat route, add beta banner

"AI Gateway" in the topnav view switcher only called setMode(), which
is meaningful inside the dashboard SPA shell but a no-op on /chat,
which lives outside it (only "Chat" had a real navigation). Now
switching modes from the chat route does a real navigation back to
the dashboard root.

Also adds a persistent banner across all chat routes flagging it as a
pre-v0 feature not for production use, with a feedback link.
This commit is contained in:
Krrish Dholakia 2026-07-03 15:54:44 -07:00
parent a58930e94e
commit 7109b2f61c
3 changed files with 136 additions and 96 deletions

View file

@ -2,13 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import ViewSwitcher from "./ViewSwitcher";
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const { mockUsePluginMode, mockUseUISettings, mockUsePathname, state } = vi.hoisted(() => {
const state = {
mode: "ai-gateway" as string,
setMode: vi.fn(),
plugins: [] as { name: string; display_name: string; url: string }[],
activePlugin: null as { name: string; display_name: string; url: string } | null,
enableChatUI: false,
pathname: "/ui/",
};
return {
state,
@ -19,11 +20,13 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
activePlugin: state.activePlugin,
})),
mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } } })),
mockUsePathname: vi.fn(() => state.pathname),
};
});
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: mockUsePathname }));
// Deterministic hrefs so navigation assertions don't depend on server_root_path.
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` }));
@ -42,6 +45,7 @@ describe("ViewSwitcher", () => {
state.mode = "ai-gateway";
state.plugins = [];
state.enableChatUI = false;
state.pathname = "/ui/";
state.setMode.mockClear();
});
@ -100,6 +104,23 @@ describe("ViewSwitcher", () => {
expect(state.setMode).not.toHaveBeenCalled();
});
it("navigates back to the dashboard when a mode entry is picked from the chat route", async () => {
state.enableChatUI = true;
state.pathname = "/ui/chat";
state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }];
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("AI Gateway")).toBeInTheDocument());
act(() => {
fireEvent.click(screen.getByText("AI Gateway"));
});
expect(state.setMode).toHaveBeenCalledWith("ai-gateway");
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("hides the Chat entry from everyone when disabled", async () => {
state.enableChatUI = false;
state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }];

View file

@ -61,6 +61,11 @@ export default function ViewSwitcher() {
return;
}
setMode(key);
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
// so switching modes from there needs a real navigation, not just state.
if (isChatRoute) {
window.location.assign(migratedHref(""));
}
};
return (

View file

@ -80,105 +80,119 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
const isChatsRoute = pathname === CHAT_ROUTES.chats;
return (
<div className="flex h-full w-full bg-background overflow-hidden">
<div
className="shrink-0 bg-secondary border-r flex flex-col overflow-hidden"
style={{ width: sidebarCollapsed ? 56 : 260, transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)" }}
>
<div className="flex items-center justify-start px-2.5 py-3 shrink-0">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setSidebarCollapsed((v) => !v)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground flex items-center cursor-pointer transition-colors"
style={{ background: "none", border: "none" }}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex h-full w-full flex-col bg-background overflow-hidden">
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-1.5 text-center text-[13px] text-amber-800">
This is a pre-v0 feature. Do not use in production, it may change unexpectedly. Please share feedback{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32085"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
here
</a>
.
</div>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="shrink-0 bg-secondary border-r flex flex-col overflow-hidden"
style={{ width: sidebarCollapsed ? 56 : 260, transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)" }}
>
<div className="flex items-center justify-start px-2.5 py-3 shrink-0">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setSidebarCollapsed((v) => !v)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground flex items-center cursor-pointer transition-colors"
style={{ background: "none", border: "none" }}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="px-2 pb-1 shrink-0">
<NavItem
icon={<Pencil className="h-4 w-4" />}
label="New chat"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Search className="h-4 w-4" />}
label="Search chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
</div>
<Separator className="mx-2 shrink-0" />
<div className="px-2 py-1 shrink-0">
<NavItem
icon={<MessageSquare className="h-4 w-4" />}
label="Chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
active={isChatsRoute}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<LayoutGrid className="h-4 w-4" />}
label="Integrations"
onClick={() => router.push(CHAT_ROUTES.integrations)}
active={pathname === CHAT_ROUTES.integrations}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<KeyRound className="h-4 w-4" />}
label="Credentials"
onClick={() => router.push(CHAT_ROUTES.credentials)}
active={pathname === CHAT_ROUTES.credentials}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Lock className="h-4 w-4" />}
label="API Keys"
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
active={pathname === CHAT_ROUTES.apiKeys}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
onClick={() => router.push(CHAT_ROUTES.usage)}
active={pathname === CHAT_ROUTES.usage}
collapsed={sidebarCollapsed}
/>
</div>
<Separator className="mx-2 shrink-0" />
{!sidebarCollapsed && (
<div className="flex-1 overflow-hidden flex flex-col">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
}}
onNewChat={() => router.push(CHAT_ROUTES.chats)}
onRename={renameConversation}
<div className="px-2 pb-1 shrink-0">
<NavItem
icon={<Pencil className="h-4 w-4" />}
label="New chat"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Search className="h-4 w-4" />}
label="Search chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
</div>
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</div>
<Separator className="mx-2 shrink-0" />
<div className="px-2 py-1 shrink-0">
<NavItem
icon={<MessageSquare className="h-4 w-4" />}
label="Chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
active={isChatsRoute}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<LayoutGrid className="h-4 w-4" />}
label="Integrations"
onClick={() => router.push(CHAT_ROUTES.integrations)}
active={pathname === CHAT_ROUTES.integrations}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<KeyRound className="h-4 w-4" />}
label="Credentials"
onClick={() => router.push(CHAT_ROUTES.credentials)}
active={pathname === CHAT_ROUTES.credentials}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Lock className="h-4 w-4" />}
label="API Keys"
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
active={pathname === CHAT_ROUTES.apiKeys}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
onClick={() => router.push(CHAT_ROUTES.usage)}
active={pathname === CHAT_ROUTES.usage}
collapsed={sidebarCollapsed}
/>
</div>
<Separator className="mx-2 shrink-0" />
{!sidebarCollapsed && (
<div className="flex-1 overflow-hidden flex flex-col">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
}}
onNewChat={() => router.push(CHAT_ROUTES.chats)}
onRename={renameConversation}
/>
</div>
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</div>
</div>
</div>
);
};