refactor(ui): align Chat with dashboard styling (#50034)

* refactor(ui): align Chat with dashboard styling

* refactor(ui): remove redundant Chats navigation

* test(ui): assert preserved chat messages after blocked edits

* fix(ui): close Chat mobile navigation when selecting the logo

* fix(ui): tidy collapsed workspace navigation
This commit is contained in:
moe-berri 2026-11-05 12:48:27 +08:00 • committed by GitHub
parent 796f751603
commit c4faa55249
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 500 additions and 1181 deletions

View file

@ -13,6 +13,7 @@ const { router, route } = vi.hoisted(() => ({
vi.mock("next/navigation", () => ({
useRouter: () => router,
usePathname: () => route.pathname,
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
@ -86,9 +87,9 @@ describe("Chat layout product navigation", () => {
["public light and local dark", [LIGHT_LOGO, null], [false, true], [LIGHT_LOGO, "/get_image?theme=dark"]],
["local light and public dark", [null, DARK_LOGO], [true, false], ["/get_image", DARK_LOGO]],
["public light only", [LIGHT_LOGO, null], [false, false], [LIGHT_LOGO, LIGHT_LOGO]],
["public dark only", [null, DARK_LOGO], [false, false], [DARK_LOGO, DARK_LOGO]],
["public dark only", [null, DARK_LOGO], [false, false], ["/get_image", DARK_LOGO]],
["local light only", [null, null], [true, false], ["/get_image", "/get_image"]],
["local dark only", [null, null], [false, true], ["/get_image?theme=dark", "/get_image?theme=dark"]],
["local dark only", [null, null], [false, true], ["/get_image", "/get_image?theme=dark"]],
] as const)(
"should render %s organization logos from the theme response",
async (
@ -106,8 +107,9 @@ describe("Chat layout product navigation", () => {
);
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
const logo = await screen.findByRole("img", { name: "Organization logo" });
const [light, dark] = within(logo).getAllByAltText("");
const logo = await screen.findByRole("link", { name: "Chat home" });
const light = within(logo).getByAltText("LiteLLM");
const dark = within(logo).getByAltText("");
const source = (path: string) => (path.startsWith("/") ? `${getProxyBaseUrl()}${path}` : path);
expect(light).toHaveAttribute("src", source(lightSource));
expect(dark).toHaveAttribute("src", source(darkSource));
@ -116,7 +118,7 @@ describe("Chat layout product navigation", () => {
},
);
it("should omit organization branding when no logo is configured", async () => {
it("should use the bundled logo when no organization logo is configured", async () => {
const user = userEvent.setup();
renderWithProviders(
<ChatLayout>
@ -126,7 +128,7 @@ describe("Chat layout product navigation", () => {
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Organization logo" })).not.toBeInTheDocument();
expect(screen.getByAltText("LiteLLM")).toHaveAttribute("src", `${getProxyBaseUrl()}/get_image`);
});
it.each([
@ -145,6 +147,7 @@ describe("Chat layout product navigation", () => {
);
expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument();
expect(localStorage.getItem("litellm_plugin_mode")).toBe(plugin.name);
@ -178,6 +181,7 @@ describe("Chat layout product navigation", () => {
);
expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument();
await user.click(screen.getByRole("menuitem", { name: "AI Gateway", exact: true }));

View file

@ -39,9 +39,6 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider accessToken={accessToken}>
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossOrigin="anonymous" />
<link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=general-sans@1&display=swap" />
<div className="flex h-dvh flex-col">
<div className="min-h-0 flex-1">
<ChatShellProvider

View file

@ -137,7 +137,7 @@ describe("Chat Models page", () => {
const user = userEvent.setup();
renderWithProviders(<ChatModelsPage />);
expect(screen.getByRole("heading", { name: "Models", level: 1 })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Models", level: 2 })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: /^research-private/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^image-studio/ })).toBeInTheDocument();

View file

@ -3,21 +3,14 @@
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import PublicModelHub from "@/components/public_model_hub";
import { useChatShell } from "@/contexts/ChatShellContext";
import { useTranslation } from "react-i18next";
import { isAdminRole } from "@/utils/roles";
export default function ChatModelsPage() {
const { t } = useTranslation();
const { accessToken, userRole, premiumUser } = useChatShell();
return (
<div className="moyai-models-page">
<div className="moyai-models-content">
<header className="moyai-models-header">
<p className="text-xs font-medium text-muted-foreground">{t("chat.modelsPage.eyebrow")}</p>
<h1>{t("chat.modelsPage.title")}</h1>
<p className="text-sm text-muted-foreground">{t("chat.modelsPage.description")}</p>
</header>
{isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}

View file

@ -139,7 +139,7 @@ it("should switch the composer placeholder with deep research on home and in a c
const home = renderChat();
await screen.findByRole("button", { name: "research-chat" });
const homeEditor = screen.getByRole("textbox", { name: "Message" });
expect(homeEditor).toHaveAttribute("placeholder", "Ask a question, attach a file, or paste something to work on…");
expect(homeEditor).toHaveAttribute("placeholder", "Ask anything…");
const enableResearch = async () => {
await user.click(screen.getByRole("button", { name: "Connect tools" }));
@ -148,7 +148,7 @@ it("should switch the composer placeholder with deep research on home and in a c
await enableResearch();
expect(homeEditor).toHaveAttribute("placeholder", "What should I research on the web?");
await user.click(screen.getByRole("button", { name: "Turn off deep research" }));
expect(homeEditor).toHaveAttribute("placeholder", "Ask a question, attach a file, or paste something to work on…");
expect(homeEditor).toHaveAttribute("placeholder", "Ask anything…");
home.unmount();
const conversationId = "saved-chat";
@ -266,7 +266,9 @@ it("should retain ordinary streaming when background support is unavailable", as
expect(requests[0]).not.toHaveProperty("background");
expect(requests[0]).not.toHaveProperty("instructions");
expect(requests[0].tools).not.toContainEqual(expect.objectContaining({ type: "web_search" }));
expect(screen.getByText("Keep this tab open. Background runs are not available for this chat.")).toBeVisible();
expect(
screen.queryByText("Keep this tab open. Background runs are not available for this chat."),
).not.toBeInTheDocument();
});
it("should let the user reconnect after an authentication failure without resubmitting work", async () => {

View file

@ -260,7 +260,8 @@ describe("chat image workflows", () => {
await user.type(screen.getByRole("textbox", { name: "Edit message" }), "Explain the findings");
await user.click(screen.getByRole("button", { name: "Save & Send" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Choose a chat model before resending this message.");
expect(screen.getByRole("heading", { name: "Explain the experiment" })).toBeInTheDocument();
expect(screen.getByText("Explain the experiment", { exact: true })).toBeInTheDocument();
expect(screen.queryByText("Explain the findings", { exact: true })).not.toBeInTheDocument();
expect(screen.getByText("I can see the uploaded image.")).toBeInTheDocument();
expect(requests).toHaveLength(1);
});

View file

@ -72,7 +72,7 @@ const ON_TOTAL_LATENCY_INDEX = 24;
async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise<void> {
renderWithProviders(<ChatConversationPage />, { onUrlUpdate });
expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Ask a question, attach a file, or paste something to work on…"), {
fireEvent.change(screen.getByPlaceholderText("Ask anything…"), {
target: { value: "How much did this cost?" },
});
fireEvent.click(screen.getByRole("button", { name: "Send" }));

View file

@ -503,7 +503,7 @@ export default function ChatConversationPage() {
return t("chat.page.backgroundHint.cancelling");
if (background.pending && storageUnavailable) return t("chat.page.backgroundHint.unsaved");
if (background.pending) return t("chat.page.backgroundHint.running");
if (useBackground) return null;
if (useBackground || (!isStreaming && !isRequestInFlight)) return null;
return t("chat.page.backgroundHint.unavailable");
};
@ -694,9 +694,6 @@ export default function ChatConversationPage() {
{hint}
</p>
)}
<p className="moyai-chat-home-composer-hint">
{mode === "image" ? t("chat.page.composerHint.image") : t("chat.page.composerHint.chat")}
</p>
</div>
);
};
@ -724,19 +721,12 @@ export default function ChatConversationPage() {
researchMode={research}
draft={inputText}
busy={isStreaming}
conversations={conversations}
onSelectConversation={(id) => {
void setConversationIdInUrl(id);
}}
onPrompt={setInputText}
>
{inputBar(false)}
</MoyaiChatHome>
) : (
<div className="moyai-chat-conversation">
<header className="moyai-chat-conversation-header">
<h1>{activeConversation!.title}</h1>
</header>
<div className="moyai-chat-transcript-layout">
<div ref={messagesScrollRef} className="moyai-chat-transcript" style={{ overflowAnchor: "none" }}>
<ChatMessages

View file

@ -66,7 +66,7 @@ describe("chat page URL state with ChatShellProvider", () => {
expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument();
act(() => {
fireEvent.change(screen.getByPlaceholderText("Ask a question, attach a file, or paste something to work on…"), {
fireEvent.change(screen.getByPlaceholderText("Ask anything…"), {
target: { value: "How much did this cost?" },
});
});
@ -77,7 +77,7 @@ describe("chat page URL state with ChatShellProvider", () => {
await waitFor(() => expect(window.location.search).toMatch(/^\?id=/));
const conversationId = new URLSearchParams(window.location.search).get("id");
expect(conversationId).toBeTruthy();
expect(await screen.findByRole("heading", { level: 1, name: "How much did this cost?" })).toBeInTheDocument();
expect(await screen.findByText("How much did this cost?")).toBeInTheDocument();
expect(await screen.findByText("The estimated cost is one cent.")).toBeInTheDocument();
await waitFor(() =>
expect(localStorage.getItem("litellm_chat_history_v1:url-test-user")).toContain(conversationId),
@ -86,12 +86,11 @@ describe("chat page URL state with ChatShellProvider", () => {
act(() => window.history.back());
await waitFor(() => expect(window.location.search).toBe(""));
expect(
await screen.findByPlaceholderText("Ask a question, attach a file, or paste something to work on…"),
).toBeInTheDocument();
expect(await screen.findByPlaceholderText("Ask anything…")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "What are we working on?" })).toBeInTheDocument();
expect(screen.queryByText("The estimated cost is one cent.")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "How much did this cost?" })).toBeInTheDocument();
act(() => window.history.forward());
expect(await screen.findByText("The estimated cost is one cent.")).toBeInTheDocument();
expect(window.location.pathname).toBe("/chat");
});
});

View file

@ -485,339 +485,12 @@
border: 1px solid var(--neutral-border);
}
/* Standalone Chat only. Moyai's semantic glass treatment (frontend/theme.css,
1118ab0) is adapted to the existing LiteLLM controls and theme switch. */
.chat-theme {
--background: #ffffff;
--foreground: #000000;
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: #7145b8;
--primary-foreground: #ffffff;
--secondary: #f0f0f2;
--secondary-foreground: var(--foreground);
--muted: var(--secondary);
--muted-foreground: #4b4b50;
--accent: var(--secondary);
--accent-foreground: var(--foreground);
--border: #e2e2e5;
--input: #d2d2d7;
--ring: #767680;
--sidebar: #f8f8f8;
--sidebar-foreground: var(--foreground);
--sidebar-accent: #eeeaf4;
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: var(--border);
--chat-canvas: #ededf0;
--chat-shell: #ffffff;
--chat-message: #f5f5f6;
--chat-message-border: #e2e2e5;
--chat-home-editor-border: #d2d2d7;
--chat-focus-border: #767680;
--chat-message: var(--muted);
--chat-message-border: var(--border);
--chat-home-editor-border: var(--border);
--chat-focus-border: var(--ring);
--chat-prompt-muted: var(--muted-foreground);
--chat-reading: var(--background);
--chat-glass-surface: var(--background);
--chat-glass-chrome: var(--sidebar);
--chat-glass-elevated: var(--background);
--chat-glass-line: var(--border);
--chat-glass-sheen: none;
--chat-glass-blur: none;
--chat-glass-inset: inset 0 0 0 transparent;
--chat-glass-shadow: 0 8px 24px rgb(36 36 38 / 6%);
--chat-backdrop: none;
}
.dark .chat-theme {
--background: #181818;
--foreground: #ededed;
--primary: #b69af7;
--primary-foreground: #17131f;
--secondary: #222225;
--muted-foreground: #aeb0b5;
--border: #2b2b2f;
--input: #3c3c42;
--ring: #77777e;
--sidebar: #151515;
--sidebar-accent: #292430;
--chat-canvas: #0b0b0b;
--chat-shell: #111111;
--chat-message: #1c1c1e;
--chat-message-border: #303034;
--chat-home-editor-border: #3c3c42;
--chat-focus-border: #77777e;
--chat-prompt-muted: #a3a3a8;
--chat-glass-shadow: 0 8px 24px rgb(0 0 0 / 15%);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.chat-theme {
--chat-shell: rgb(255 255 255 / 38%);
--chat-reading: rgb(255 255 255 / 80%);
--chat-glass-surface: rgb(255 255 255 / 20%);
--chat-glass-chrome: rgb(255 255 255 / 40%);
--chat-glass-elevated: rgb(255 255 255 / 92%);
--chat-glass-line: rgb(73 77 89 / 14%);
--chat-glass-sheen: linear-gradient(125deg, rgb(255 255 255 / 24%), transparent 45%, rgb(255 255 255 / 4%));
--chat-glass-blur: blur(14px) saturate(110%);
--chat-glass-inset: inset 0 1px 0 rgb(255 255 255 / 75%), inset 1px 0 0 rgb(255 255 255 / 40%);
--chat-glass-shadow: var(--chat-glass-inset), 0 12px 32px rgb(41 45 58 / 10%);
--chat-backdrop: radial-gradient(ellipse at 3% 0%, #b3bac6 0%, transparent 56%),
radial-gradient(ellipse at 100% 100%, #c5cad4 0%, transparent 58%);
}
.dark .chat-theme {
--chat-shell: rgb(17 17 19 / 46%);
--chat-reading: rgb(17 17 19 / 90%);
--chat-glass-surface: rgb(28 28 31 / 32%);
--chat-glass-chrome: rgb(27 27 30 / 42%);
--chat-glass-elevated: rgb(27 27 30 / 92%);
--chat-glass-line: rgb(255 255 255 / 16%);
--chat-glass-sheen: linear-gradient(125deg, rgb(255 255 255 / 8%), transparent 45%, rgb(255 255 255 / 2%));
--chat-glass-inset: inset 0 1px 0 rgb(255 255 255 / 16%), inset 1px 0 0 rgb(255 255 255 / 4%);
--chat-glass-shadow: var(--chat-glass-inset), 0 12px 32px rgb(0 0 0 / 24%);
--chat-backdrop: radial-gradient(ellipse at 4% 0%, #1b1c1f 0%, transparent 52%),
radial-gradient(ellipse at 100% 100%, #151619 0%, transparent 58%);
}
}
.chat-workspace {
background: var(--chat-canvas);
background-image: linear-gradient(var(--chat-shell), var(--chat-shell)), var(--chat-backdrop);
font-family: var(--font-moyai-chat, ui-sans-serif, system-ui, sans-serif);
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.chat-theme .chat-glass-pane:not(:has(.moyai-chat-home)) {
background: var(--chat-reading);
}
.chat-theme .chat-glass-sidebar,
.chat-theme.chat-mobile-sidebar {
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
border-color: var(--chat-glass-line);
-webkit-backdrop-filter: var(--chat-glass-blur);
backdrop-filter: var(--chat-glass-blur);
box-shadow: var(--chat-glass-inset);
}
.chat-theme.chat-mobile-sidebar {
width: min(320px, calc(100vw - 32px));
background-color: var(--chat-glass-elevated);
}
.chat-theme .chat-nav-item,
.chat-theme .chat-new-conversation {
font-size: 13px;
border-radius: 8px;
}
/* Moyai main.tsx / brand.css / sidebar-layout.css at 1118ab0.
Keep the rail geometry local to standalone Chat. */
.moyai-chat-shell {
position: relative;
}
.chat-theme .moyai-chat-sidebar,
.chat-theme.moyai-chat-sidebar {
display: flex;
flex: 0 0 240px;
flex-direction: column;
width: 240px;
min-height: 0;
gap: 2px;
padding: 8px 10px;
overflow: hidden;
border-right: 1px solid var(--chat-glass-line);
}
.moyai-chat-brand-row {
display: flex;
align-items: center;
flex-shrink: 0;
min-height: 40px;
gap: 4px;
}
.chat-theme .moyai-chat-workspace-switcher {
min-width: 0;
height: 36px;
flex: 1;
justify-content: flex-start;
gap: 8px;
padding: 0 6px;
border-color: transparent;
background: transparent;
color: var(--sidebar-foreground);
box-shadow: none;
font-size: 15px;
line-height: 1.25;
font-weight: 550;
letter-spacing: -0.02em;
}
.chat-theme .moyai-chat-workspace-switcher:hover,
.chat-theme .moyai-chat-workspace-switcher[data-popup-open] {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
.moyai-chat-workspace-switcher [data-slot="workspace-icon"] {
width: 28px;
height: 28px;
}
.moyai-chat-workspace-switcher [data-slot="workspace-label"] {
flex: 1;
text-align: left;
}
.moyai-chat-mobile-header .moyai-chat-workspace-switcher {
flex: none;
}
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher) {
width: 28px;
height: 28px;
color: var(--foreground);
}
.chat-theme .moyai-chat-rail-button {
height: 32px;
min-height: 32px;
flex-shrink: 0;
gap: 9px;
padding: 0 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 400;
}
.moyai-chat-destinations {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
}
.moyai-chat-destinations svg,
.moyai-chat-sidebar > .chat-new-conversation svg {
width: 15px;
height: 15px;
}
.moyai-chat-sidebar .moyai-chat-rail-button:hover,
.moyai-chat-history .chat-conversation-row:hover {
background: var(--secondary);
color: var(--foreground);
}
.moyai-chat-history .moyai-chat-history-heading {
height: 28px;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
}
.moyai-chat-history .chat-conversation-row {
min-height: 48px;
border-radius: 6px;
}
.moyai-chat-history .chat-conversation-row > [data-slot="button"] {
height: auto;
min-height: 48px;
padding: 4px 10px;
border-radius: 6px;
font-weight: 400;
line-height: 20px;
}
.moyai-chat-empty-history {
padding: 2px 10px 6px 25px;
font-size: 12px;
line-height: 18px;
}
.moyai-chat-conversation-meta {
line-height: 18px;
}
.moyai-chat-account {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
padding: 8px 4px 0;
border-top: 1px solid var(--sidebar-border);
}
.moyai-chat-account > [data-slot="button"] {
flex: none;
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
color: var(--foreground);
}
.chat-theme.moyai-chat-collapsed .moyai-chat-sidebar {
flex-basis: 52px;
width: 52px;
padding: 12px 8px 8px;
}
.moyai-chat-collapsed .moyai-chat-sidebar > :not(.moyai-chat-brand-row),
.moyai-chat-collapsed [data-slot="workspace-label"],
.moyai-chat-collapsed [data-slot="workspace-chevron"],
.moyai-chat-collapsed .moyai-chat-search {
display: none;
}
.moyai-chat-collapsed .moyai-chat-brand-row {
flex-direction: column;
justify-content: center;
gap: 8px;
}
.moyai-chat-collapsed .moyai-chat-workspace-switcher {
flex: none;
width: 36px;
padding: 4px;
}
@media (min-width: 601px) and (max-width: 1250px) {
.chat-theme .moyai-chat-sidebar {
width: 230px;
flex-basis: 230px;
}
}
@media (max-width: 600px) {
.moyai-chat-shell {
flex-direction: column;
}
.chat-theme.moyai-chat-sidebar {
width: calc(100vw - 56px);
}
.chat-theme .moyai-chat-rail-button,
.chat-theme .moyai-chat-workspace-switcher,
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher),
.moyai-chat-account [data-slot="button"] {
min-height: 44px;
}
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher),
.moyai-chat-account [data-slot="button"] {
width: 44px;
}
}
@media (hover: none) {
@ -826,40 +499,6 @@
}
}
@media (max-width: 600px) {
.chat-theme .chat-nav-item,
.chat-theme .chat-new-conversation,
.chat-theme .chat-conversation-row > [data-slot="button"] {
min-height: 44px;
}
}
@media (prefers-reduced-transparency: reduce), (forced-colors: active) {
.chat-theme,
.dark .chat-theme {
--chat-shell: var(--background);
--chat-reading: var(--background);
--chat-glass-surface: var(--background);
--chat-glass-chrome: var(--sidebar);
--chat-glass-elevated: var(--background);
--chat-glass-line: var(--border);
--chat-glass-sheen: none;
--chat-glass-blur: none;
--chat-glass-inset: inset 0 0 0 transparent;
--chat-glass-shadow: none;
--chat-backdrop: none;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-theme [data-slot="button"],
.chat-theme .chat-conversation-row,
.chat-theme .chat-conversation-actions,
.chat-theme.chat-mobile-sidebar {
transition: none;
}
}
/* A dialog opened from inside another dialog reads as a drill-down, not a stack: base-ui stamps
this attribute on the parent while a child is open, so the parent steps aside instead of
showing its own edges around a differently sized child. */

View file

@ -15,11 +15,15 @@ import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
export function DashboardHeader({
accessToken,
title: pageTitle,
workspace = "gateway",
navigationTrigger,
deploymentHealth,
deploymentHealthLink,
}: {
accessToken: string | null;
title?: string;
workspace?: "gateway" | "chat";
navigationTrigger?: ReactNode;
deploymentHealth?: ReactNode;
deploymentHealthLink?: ReactNode;
@ -27,7 +31,7 @@ export function DashboardHeader({
const term = useTerminology();
const { title } = getBreadcrumb(usePathname());
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSelector = isControlPlane && selectedWorker !== null;
const showWorkerSelector = workspace === "gateway" && isControlPlane && selectedWorker !== null;
const handleWorkerSwitch = (workerId: string) => {
clearTokenCookies();
clearStoredReturnUrl();
@ -40,11 +44,13 @@ export function DashboardHeader({
<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>
<h1 className="truncate text-sm font-medium">{pageTitle ?? term(title)}</h1>
</div>
<CommandPaletteTrigger className="hidden w-56 @min-[640px]:flex @min-[1000px]:w-80" />
{workspace === "gateway" && (
<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" />
{workspace === "gateway" && <CommandPaletteTrigger mobile className="size-9 @min-[640px]:hidden" />}
{showWorkerSelector && (
<>
<div className="@min-[840px]:hidden">

View file

@ -9,7 +9,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronDown, ChevronsUpDown, MessageSquare, PanelsTopLeft } from "lucide-react";
import { useSidebar } from "@/components/shared/Sidebar";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { uiHref } from "@/utils/uiHref";
@ -52,6 +53,7 @@ interface ViewSwitcherProps {
export default function ViewSwitcher({ className, contentClassName }: ViewSwitcherProps) {
const { t } = useTranslation();
const term = useTerminology();
const { collapsed } = useSidebar();
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
const pathname = usePathname();
@ -63,6 +65,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
const isChatRoute = isWorkspaceRoute(normalizedPathname, CHAT);
const isMoyaiRoute = isWorkspaceRoute(normalizedPathname, MOYAI);
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
const WorkspaceIcon = isChatRoute ? MessageSquare : PanelsTopLeft;
const gatewayLabel = term(t("shell.viewSwitcher.aiGateway"));
const activeLabel = activeWorkspaceLabel(
@ -142,14 +145,29 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
type="button"
variant="outline"
size="sm"
className={cn("max-w-[220px] gap-1.5 px-3 dark:bg-transparent", className)}
title={collapsed ? t("shell.viewSwitcher.trigger", { workspace: activeLabel }) : undefined}
className={cn(
"max-w-[220px] gap-1.5 px-3 dark:bg-transparent",
className,
collapsed &&
"mx-auto flex size-9 justify-center gap-0.5 border-sidebar-border p-0 text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
/>
}
>
<span data-slot="workspace-label" className="truncate">
{activeLabel}
</span>
<ChevronsUpDown data-slot="workspace-chevron" className="size-3.5 flex-none text-muted-foreground" />
{collapsed ? (
<>
<WorkspaceIcon className="size-[18px]" />
<ChevronDown className="size-2.5 text-muted-foreground" />
</>
) : (
<>
<span data-slot="workspace-label" className="truncate">
{activeLabel}
</span>
<ChevronsUpDown data-slot="workspace-chevron" className="size-3.5 flex-none text-muted-foreground" />
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className={cn("w-auto", contentClassName)}>
{items.map((item) => (

View file

@ -0,0 +1,73 @@
"use client";
import { useTranslation } from "react-i18next";
import { ChevronsUpDown, LogOut, Settings } from "lucide-react";
import { useChatShell } from "@/contexts/ChatShellContext";
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
export default function ChatAccountMenu({
collapsed,
onSettings,
onLogout,
}: {
collapsed: boolean;
onSettings: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const { userEmail, userId } = useChatShell();
const identity = userEmail || userId;
const name = navAccountDisplayName(userEmail, userId);
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
aria-label={t("chat.shell.accountMenu", { identity })}
className={cn(
"h-auto w-full gap-2.5 rounded-lg hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "justify-start px-2 py-1.5",
)}
/>
}
>
<Avatar className="size-[30px]">
<AvatarFallback className="bg-sidebar-accent text-sidebar-accent-foreground text-xs font-medium">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium">{name}</span>
<ChevronsUpDown className="size-4 text-muted-foreground" />
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-60">
<div className="truncate px-2 py-2 text-xs text-muted-foreground" title={identity}>
{identity}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings />
{t("chat.shell.settings")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogout}>
<LogOut />
{t("chat.shell.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import type { ReactElement } from "react";
@ -8,8 +8,9 @@ import ChatShell from "./ChatShell";
const render = (ui: ReactElement) => renderWithProviders(<ThemeProvider>{ui}</ThemeProvider>);
const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.hoisted(() => ({
const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery, mockLogout } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockLogout: vi.fn(),
mockUsePathname: vi.fn(() => "/ui/chat"),
mockUseMediaQuery: vi.fn(() => true),
mockUseChatShell: vi.fn(() => ({
@ -26,16 +27,19 @@ const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.ho
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: mockUsePathname,
useSearchParams: () => new URLSearchParams(),
}));
// Deterministic hrefs so navigation/active-state assertions don't depend on server_root_path.
vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
vi.mock("@/utils/uiHref", () => ({
uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui",
routeSegmentForPathname: (path: string) => path.replace(/^\/ui\//, "").split("/")[0],
}));
vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: { enable_chat_ui: true } } }),
}));
vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ useLogout: () => vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ useLogout: () => mockLogout }));
vi.mock("usehooks-ts", () => ({ useMediaQuery: mockUseMediaQuery }));
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
describe("ChatShell", () => {
beforeEach(() => {
@ -52,19 +56,18 @@ describe("ChatShell", () => {
mockUseMediaQuery.mockReturnValue(true);
});
it("should render the workspace and mark Chats active on the base chat route", () => {
it("should render the chat workspace with a single New Chat action", () => {
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Chats" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "New Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Models" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Account menu/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search chats" })).toBeInTheDocument();
});
it("should mark Models active on its route and tolerate a trailing slash", () => {
@ -76,7 +79,6 @@ describe("ChatShell", () => {
);
expect(screen.getByRole("button", { name: "Models" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
});
@ -88,8 +90,7 @@ describe("ChatShell", () => {
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Workspace" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search chats" })).toBeInTheDocument();
});
it("should navigate through the workspace menu and retain all chat destinations", async () => {
@ -136,13 +137,13 @@ describe("ChatShell", () => {
<div />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: "Close sidebar" }));
expect(screen.getByRole("button", { name: "Open sidebar" })).toHaveAttribute("aria-expanded", "false");
await user.click(screen.getByRole("button", { name: "Collapse sidebar" }));
expect(screen.getByRole("button", { name: "Expand sidebar" })).toHaveAttribute("aria-expanded", "false");
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
expect(await screen.findByRole("menuitem", { name: "AI Gateway", exact: true })).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Open sidebar" }));
expect(screen.getByRole("button", { name: "Close sidebar" })).toHaveAttribute("aria-expanded", "true");
await user.click(screen.getByRole("button", { name: "Expand sidebar" }));
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toHaveAttribute("aria-expanded", "true");
});
it("should open mobile navigation and close it after selecting Models", async () => {
@ -155,7 +156,6 @@ describe("ChatShell", () => {
);
expect(screen.queryByRole("navigation", { name: "Chat workspace" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Models" }));
@ -165,6 +165,25 @@ describe("ChatShell", () => {
expect(screen.getByText("Conversation content")).toBeInTheDocument();
});
it("should dismiss mobile navigation when the Chat home logo is selected", async () => {
mockUseMediaQuery.mockReturnValue(false);
const user = userEvent.setup();
render(
<ChatShell>
<input aria-label="Message" />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
const home = await screen.findByRole("link", { name: "Chat home" });
expect(home).toHaveAttribute("href", "/ui/chat");
await user.click(home);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await user.click(screen.getByRole("textbox", { name: "Message" }));
expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
});
it("should close mobile navigation with Escape and restore trigger focus", async () => {
mockUseMediaQuery.mockReturnValue(false);
const user = userEvent.setup();
@ -182,4 +201,48 @@ describe("ChatShell", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
it("should keep search scoped to conversations when the sidebar is collapsed", async () => {
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: "Collapse sidebar" }));
fireEvent.keyDown(document, { key: "k", code: "KeyK", ctrlKey: true });
expect(await screen.findByRole("dialog", { name: "Search conversations" })).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument();
await user.keyboard("{Escape}");
expect(await screen.findByRole("button", { name: "Collapse sidebar" })).toHaveAttribute("aria-expanded", "true");
});
it("should focus conversation search when the mobile sidebar starts closed", async () => {
mockUseMediaQuery.mockReturnValue(false);
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
fireEvent.keyDown(document, { key: "k", code: "KeyK", ctrlKey: true });
const search = await screen.findByRole("textbox", { name: "Search conversations" });
await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "release notes");
expect(search).toHaveValue("release notes");
});
it("should preserve the Chat settings route and sign-out action in the account menu", async () => {
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: /Account menu/ }));
await user.click(await screen.findByRole("menuitem", { name: "Settings" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/credentials");
await user.click(screen.getByRole("button", { name: /Account menu/ }));
await user.click(await screen.findByRole("menuitem", { name: "Sign out" }));
expect(mockLogout).toHaveBeenCalledOnce();
});
});

View file

@ -1,25 +1,20 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import {
Menu,
Plus,
MessageSquare,
Network,
LayoutGrid,
KeyRound,
Lock,
BarChart3,
ScrollText,
PanelLeftClose,
PanelLeftOpen,
Search,
Settings2,
ChevronDown,
Info,
Settings,
LogOut,
} from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { useTranslation } from "react-i18next";
@ -32,11 +27,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import { Sidebar, SidebarContent, SidebarFooter, SidebarMenuButton } from "@/components/shared/Sidebar";
import { SidebarBrand } from "@/components/shared/SidebarBrand";
import ChatAccountMenu from "./ChatAccountMenu";
import { Kbd } from "@/components/ui/kbd";
import { useShortcutLabel } from "@/components/CommandPalette/useShortcutLabel";
import { useHotkeys } from "react-hotkeys-hook";
import { DashboardHeader } from "@/components/DashboardHeader";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { uiHref } from "@/utils/uiHref";
import { useChatShell } from "@/contexts/ChatShellContext";
import { useTheme } from "@/contexts/ThemeContext";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ConversationList from "./ConversationList";
@ -62,19 +63,26 @@ interface NavItemProps {
label: string;
onClick: () => void;
active?: boolean;
shortcut?: string;
}
function NavItem({ icon, label, onClick, active = false }: NavItemProps) {
function NavItem({ icon, label, onClick, active = false, shortcut }: NavItemProps) {
return (
<Button
<SidebarMenuButton
onClick={onClick}
variant="ghost"
isActive={active}
aria-label={label}
title={label}
aria-current={active ? "page" : undefined}
className={`chat-nav-item moyai-chat-rail-button w-full justify-start ${active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground"}`}
>
{icon}
<span className="flex-1 text-left">{label}</span>
</Button>
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{label}</span>
{shortcut && (
<Kbd className="bg-transparent text-muted-foreground group-data-[collapsed=true]/sidebar:hidden">
{shortcut}
</Kbd>
)}
</SidebarMenuButton>
);
}
@ -82,28 +90,24 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const router = useRouter();
const pathname = stripTrailingSlash(usePathname() ?? "");
const {
accessToken,
userEmail,
userId,
conversations,
activeConversationId,
deleteConversation,
renameConversation,
} = useChatShell();
const { accessToken, conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
const handleLogout = useLogout(accessToken);
const isDesktop = useMediaQuery("(min-width: 601px)", { initializeWithValue: false });
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
if (isDesktop && sidebarOpen) setSidebarOpen(false);
const collapsed = isDesktop && sidebarCollapsed;
const { logoUrl, logoUrlDark } = useTheme();
const lightLogo = logoUrl || logoUrlDark;
const darkLogo = logoUrlDark || logoUrl;
const [mobileNavigationHandle] = useState(() => DialogPrimitive.createHandle());
const shortcut = useShortcutLabel();
const openSearch = () => {
setSidebarCollapsed(false);
if (!isDesktop) setSidebarOpen(true);
setSearchOpen(true);
};
useHotkeys("mod+k", openSearch, { enableOnFormTags: true, preventDefault: true, useKey: true });
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
const accountName = userEmail?.split("@")[0] || userId || t("chat.shell.accountFallback");
const workspaceItems = [
{ label: t("chat.shell.workspaceItems.credentials"), route: routes.credentials, Icon: KeyRound },
{ label: t("chat.shell.workspaceItems.apiKeys"), route: routes.apiKeys, Icon: Lock },
@ -119,43 +123,19 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const sidebar = (
<>
<div className="moyai-chat-brand-row">
<ViewSwitcher className="moyai-chat-workspace-switcher" contentClassName="chat-theme min-w-60" />
<Button
variant="ghost"
size="icon-sm"
className="moyai-chat-search"
aria-label={t("chat.shell.searchChats")}
title={t("chat.shell.searchChatsShortcut")}
onClick={() => setSearchOpen(true)}
>
<Search className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={collapsed ? t("chat.shell.openSidebar") : t("chat.shell.closeSidebar")}
aria-expanded={!collapsed}
onClick={() => (isDesktop ? setSidebarCollapsed(!sidebarCollapsed) : setSidebarOpen(false))}
>
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
</Button>
<SidebarBrand
accessToken={accessToken}
homeHref={routes.chats}
homeLabel={t("chat.shell.home")}
onHomeClick={() => setSidebarOpen(false)}
collapsed={collapsed}
onToggleCollapsed={() => (isDesktop ? setSidebarCollapsed(!sidebarCollapsed) : setSidebarOpen(false))}
/>
<div className="space-y-1 px-3 pt-3 pb-2">
<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" />
<NavItem icon={<Plus />} label={t("chat.shell.newChat")} onClick={() => navigate(routes.chats)} />
</div>
<Button
variant="ghost"
onClick={() => navigate(routes.chats)}
className="chat-new-conversation moyai-chat-rail-button w-full justify-start"
>
<Plus className="size-4" />
{t("chat.shell.newChat")}
</Button>
<nav aria-label={t("chat.shell.workspaceNav")} className="moyai-chat-destinations">
<NavItem
icon={<MessageSquare className="size-4" />}
label={t("chat.shell.chats")}
onClick={() => navigate(routes.chats)}
active={isChatsRoute}
/>
<SidebarContent aria-label={t("chat.shell.workspaceNav")} className="flex-none">
<NavItem
icon={<Network className="size-4" />}
label={t("chat.shell.models")}
@ -171,18 +151,21 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={`chat-nav-item moyai-chat-rail-button w-full justify-start ${workspaceActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground"}`}
<SidebarMenuButton
isActive={workspaceActive}
aria-label={t("chat.shell.workspace")}
title={t("chat.shell.workspace")}
aria-current={workspaceActive ? "page" : undefined}
/>
}
>
<Settings2 className="size-4" />
<span className="flex-1 text-left">{t("chat.shell.workspace")}</span>
<ChevronDown className="size-3.5" />
<span className="flex-1 text-left group-data-[collapsed=true]/sidebar:hidden">
{t("chat.shell.workspace")}
</span>
<ChevronDown className="size-3.5 group-data-[collapsed=true]/sidebar:hidden" />
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="chat-theme min-w-44">
<DropdownMenuContent side="right" align="start" className="min-w-44">
{workspaceItems.map(({ label, route, Icon }) => (
<DropdownMenuItem
key={route}
@ -204,8 +187,9 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<div className="moyai-chat-history flex min-h-0 flex-1 flex-col overflow-hidden">
<NavItem icon={<Search />} label={t("chat.shell.searchChats")} shortcut={shortcut} onClick={openSearch} />
</SidebarContent>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-3 group-data-[collapsed=true]/sidebar:hidden">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
@ -218,91 +202,75 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
searchOpen={searchOpen}
onSearchOpenChange={setSearchOpen}
hideSearchButton
enableSearchShortcut={false}
/>
</div>
{lightLogo && darkLogo && (
<div
className="flex h-12 shrink-0 items-center px-1 pb-2"
role="img"
aria-label={t("chat.shell.organizationLogo")}
>
<Image
src={lightLogo}
alt=""
width={160}
height={32}
unoptimized
className="h-8 max-w-full object-contain object-left dark:hidden"
/>
<Image
src={darkLogo}
alt=""
width={160}
height={32}
unoptimized
className="hidden h-8 max-w-full object-contain object-left dark:block"
/>
</div>
)}
<footer className="moyai-chat-account">
<span className="min-w-0 flex-1 truncate text-[13px]" title={userEmail || userId}>
{accountName}
</span>
<ThemeToggle />
<Button
variant="ghost"
size="icon-sm"
aria-label={t("chat.shell.settings")}
onClick={() => navigate(routes.credentials)}
>
<Settings className="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={t("chat.shell.signOut")} onClick={handleLogout}>
<LogOut className="size-4" />
</Button>
</footer>
<SidebarFooter className="mt-auto">
<ChatAccountMenu
onLogout={handleLogout}
collapsed={collapsed}
onSettings={() => navigate(routes.credentials)}
/>
</SidebarFooter>
</>
);
const titles = [
{ route: routes.chats, label: t("shell.viewSwitcher.chat") },
{ route: routes.models, label: t("chat.shell.models") },
{ route: routes.integrations, label: t("chat.shell.integrations") },
...workspaceItems,
];
const activeConversation = conversations.find((conversation) => conversation.id === activeConversationId);
const title =
isChatsRoute && activeConversation?.messages.length
? activeConversation.title
: titles.find((item) => item.route === pathname)?.label ?? t("shell.viewSwitcher.chat");
return (
<div
className={`chat-theme chat-workspace moyai-chat-shell flex h-full w-full overflow-hidden text-foreground ${collapsed ? "moyai-chat-collapsed" : ""}`}
>
{isDesktop ? (
<aside aria-label={t("chat.shell.chatNavigation")} className="chat-glass-sidebar moyai-chat-sidebar">
<div className="chat-theme flex h-full w-full overflow-hidden bg-background text-foreground">
{isDesktop && (
<Sidebar aria-label={t("chat.shell.chatNavigation")} collapsed={collapsed}>
{sidebar}
</aside>
) : (
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<div className="moyai-chat-mobile-header z-chrome flex h-16 shrink-0 items-center gap-2 px-3">
</Sidebar>
)}
<Sheet handle={mobileNavigationHandle} open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent
side="left"
initialFocus={searchOpen ? false : undefined}
showCloseButton={false}
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
>
<SheetHeader className="sr-only">
<SheetTitle>{t("chat.shell.sheetTitle")}</SheetTitle>
<SheetDescription>{t("chat.shell.sheetDescription")}</SheetDescription>
</SheetHeader>
<Sidebar>{sidebar}</Sidebar>
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
accessToken={accessToken}
title={title}
workspace="chat"
navigationTrigger={
<SheetTrigger
handle={mobileNavigationHandle}
render={
<Button
variant="ghost"
size="icon-lg"
className="moyai-chat-mobile-trigger"
size="icon"
className="size-11 md:hidden"
aria-label={t("chat.shell.openChatNavigation")}
/>
}
>
<PanelLeftOpen className="size-5" />
<Menu />
</SheetTrigger>
<ViewSwitcher className="moyai-chat-workspace-switcher" contentClassName="chat-theme min-w-60" />
</div>
<SheetContent
side="left"
showCloseButton={false}
className="chat-theme chat-mobile-sidebar moyai-chat-sidebar"
>
<SheetHeader className="sr-only">
<SheetTitle>{t("chat.shell.sheetTitle")}</SheetTitle>
<SheetDescription>{t("chat.shell.sheetDescription")}</SheetDescription>
</SheetHeader>
{sidebar}
</SheetContent>
</Sheet>
)}
<main className="chat-glass-pane relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{children}</main>
}
/>
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{children}</main>
</div>
</div>
);
};

View file

@ -31,6 +31,7 @@ interface Props {
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
hideSearchButton?: boolean;
enableSearchShortcut?: boolean;
}
type DateGroup = "Recents" | "Yesterday" | "Last 7 Days" | "Older";
@ -116,7 +117,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
return (
<div
className={`chat-conversation-row group flex items-center rounded-lg transition-colors min-h-9 relative ${
className={`chat-conversation-row group flex items-center rounded-md transition-colors min-h-9 relative ${
isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "hover:bg-sidebar-accent"
}`}
>
@ -138,7 +139,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
onClick={() => onSelect(conv.id)}
aria-label={conv.title}
aria-current={isActive ? "page" : undefined}
className={`min-w-0 flex-1 justify-start text-[13px] hover:bg-sidebar-accent ${
className={`h-auto min-h-11 min-w-0 flex-1 justify-start text-[13px] hover:bg-sidebar-accent ${
isActive ? "font-medium" : ""
}`}
title={conv.title}
@ -260,7 +261,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
<Input
autoFocus
aria-label={t("chat.conversationList.searchLabel")}
placeholder="Search conversations\u2026"
placeholder={t("chat.conversationList.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
@ -303,6 +304,7 @@ const ConversationList: React.FC<Props> = ({
searchOpen,
onSearchOpenChange,
hideSearchButton = false,
enableSearchShortcut = true,
}) => {
const { t } = useTranslation();
const [localSearchOpen, setLocalSearchOpen] = useState(false);
@ -311,7 +313,8 @@ const ConversationList: React.FC<Props> = ({
const handleGlobalKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
const isSearchShortcut = e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey);
if (isSearchShortcut && !e.shiftKey) {
e.preventDefault();
setSearchModalOpen(!searchModalOpen);
}
@ -320,9 +323,10 @@ const ConversationList: React.FC<Props> = ({
);
useEffect(() => {
if (!enableSearchShortcut) return;
document.addEventListener("keydown", handleGlobalKeyDown);
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
}, [handleGlobalKeyDown]);
}, [handleGlobalKeyDown, enableSearchShortcut]);
const grouped = groupConversations(conversations);
@ -342,37 +346,24 @@ const ConversationList: React.FC<Props> = ({
</div>
)}
<ScrollArea className="flex-1 h-0 px-1.5 pt-2">
{grouped.length === 0 ? (
<div>
<div className="moyai-chat-history-heading flex items-center gap-2 text-xs text-sidebar-foreground">
{grouped.map(({ group, items }) => (
<div key={group} className="mb-2">
<div className="moyai-chat-history-heading flex items-center gap-2 px-2 pt-2 pb-1 text-xs font-normal text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
<span className="flex-1">{t("chat.conversationList.recent")}</span>
<span>0</span>
{group === "Recents" ? t("chat.conversationList.recent") : group}
</div>
<p className="moyai-chat-empty-history text-xs text-muted-foreground">
{t("chat.conversationList.empty")}
</p>
{items.map((conv) => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onSelect={onSelect}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
) : (
grouped.map(({ group, items }) => (
<div key={group} className="mb-2">
<div className="moyai-chat-history-heading flex items-center gap-2 px-2 pt-2 pb-1 text-xs font-normal text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
{group === "Recents" ? t("chat.conversationList.recent") : group}
</div>
{items.map((conv) => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onSelect={onSelect}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
))
)}
))}
</ScrollArea>
</div>

View file

@ -2,7 +2,7 @@
Tactical guide for building chat UI features. Copy patterns exactly — don't improvise spacing, colors, or components.
The standalone `/ui/chat` workspace reuses Moyai's workspace structure and visual rules from `frontend/theme.css`, `native/web/style.css`, `brand.css`, `sidebar-layout.css`, and `home.css` at Moyai commit `1118ab0` (October 20, 2026). This is an intentional visual departure from the dashboard: neutral white/charcoal surfaces, restrained purple actions, and translucent navigation and composers. Shared controls and chat behavior remain owned by the existing LiteLLM primitives.
The standalone `/ui/chat` workspace shares the dashboard’s navigation primitives, header, account menu, typography and theme tokens. The previous Moyai glass treatment made Chat look like a separate product. Keep Chat’s conversation tools and route-specific navigation inside the shared visual language.
> **Agents:** Read `AGENTS.md` alongside this file. It contains decision trees, the pre-commit checklist, and the rules that govern when to use each pattern here. This file is the _what_; `AGENTS.md` is the _when and how_.
@ -11,10 +11,10 @@ The standalone `/ui/chat` workspace reuses Moyai's workspace structure and visua
## Stack
- **Next.js 16** App Router, TypeScript
- **Tailwind CSS v4** — shared primitives and utilities, with the standalone Moyai port scoped in `globals.css` (theme and rail) and `moyai/moyai-chat.css` (home, composer and transcript)
- **Tailwind CSS v4** — shared primitives and utilities, with Chat-specific content layout in `moyai/moyai-chat.css` (home, composer and transcript)
- **shadcn/ui**: import from `@/components/ui/*`. Available today: `alert-dialog`, `badge`, `bubble`, `button`, `card`, `collapsible`, `dialog`, `input`, `input-group`, `label`, `popover`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `sonner`, `switch`, `table`, `tabs`, `textarea`, `tooltip`. Use these shared primitives when composing chat features
- **lucide-react** — only icon library, no emoji in UI
- Moyai's General Sans font from its original Fontshare stylesheet, with bundled Geist fallback; Geist Mono is bundled for model names
- The dashboard font, inherited from the app root. No separate font downloads or Chat palette
---
@ -22,21 +22,17 @@ The standalone `/ui/chat` workspace reuses Moyai's workspace structure and visua
Never hardcode hex values. Use these CSS variables via Tailwind classes — all verified present in `src/app/globals.css`.
`ChatShell` applies `.chat-theme`, which scopes the palette to standalone Chat. Its dialogs and mobile Sheet also carry `.chat-theme` because they render through portals. Do not apply these overrides globally or to the Playground. Light and dark modes follow LiteLLM's existing theme switch. Keep status colors semantic.
`ChatShell` and portalled dialogs carry `.chat-theme` only for Chat-specific message and editor aliases. Do not override the shared foreground, background, sidebar, primary or font tokens. Light and dark modes use the dashboard theme
### Glass surfaces
### Surfaces
- `.chat-workspace`: static neutral backdrop, with no blur on this layout ancestor.
- `.chat-glass-sidebar`: Moyai's frosted navigation rail with its original neutral sheen.
- `.chat-glass-pane`: transparent on the home view so the source glass backdrop remains visible; quiet reading fill on conversation and workspace pages, without nested backdrop filtering.
- `.moyai-chat-home-composer`: Moyai's 10px rounded glass editor, 132px minimum desktop entry height, 14px blur, original sheen/shadow and bordered lower action row. `MoyaiChatComposer` owns its layout. Model selection sits at the right beside send. Message and code surfaces stay solid, using `--chat-message` / `--chat-message-border`; editor focus uses `--chat-focus-border`.
- `.chat-mobile-sidebar`: elevated, nearly opaque Sheet surface so navigation remains legible over conversation content.
Use solid `bg-background` content and `bg-sidebar` navigation with `border-border` separators. No glass, dot/grid backgrounds, gradients or decorative shadows. The composer has a quiet border and a visible focus state. Model selection, attachments, tools and send remain in its action row
Glass progressively enhances solid fills inside `@supports`. Reduced transparency and forced colors disable filtering, sheen and decorative shadows. Reduced motion disables chrome transitions. Use the original static dot/grid backdrop and source home heading measurements. Do not add animated backgrounds or an outer rounded frame.
The welcome view contains a heading, composer and compact prompt shortcuts. Shortcuts fill the draft without submitting. Recent conversations belong in the sidebar, without an empty-count section. Show run and storage warnings when they apply, not as permanent idle helper text
The standalone `ChatModelPicker` exposes the account's chat and image-generation models from the composer action row. Its portalled popover carries `.chat-theme`; keep model names, capabilities and selection states readable in both themes. Attachment and MCP tools stay beside the composer.
The **Models** sidebar destination opens `/ui/chat/models` inside the same shell. It reuses the existing Model Hub catalog, filters, pricing and details in a read-only `chat` variant. Admins see the authenticated catalog and other users see the published catalog, matching the dashboard's existing access rules. The page uses `.moyai-models-page` for scrolling and `.chat-model-hub-card` for the bordered Moyai surface. Portalled model details carry `.chat-theme`; catalog management and other hub tabs stay in the dashboard.
The **Models** sidebar destination opens `/ui/chat/models` inside the same shell. It reuses the existing Model Hub catalog, filters, pricing and details in a read-only `chat` variant. Admins see the authenticated catalog and other users see the published catalog, matching the dashboard's existing access rules. The page uses `.moyai-models-page` for scrolling and `.chat-model-hub-card` for the bordered dashboard surface. Portalled model details carry `.chat-theme`; catalog management and other hub tabs stay in the dashboard.
Show available providers in a single-select dropdown beside model search. “All providers” clears the provider filter while keeping the other filters. The dropdown includes provider logos and uses the Chat theme in its portal. Provider logos reuse the existing asset mapping; white logo plates preserve the colors and contrast of the original assets in both themes. Unknown or failed logos fall back to a provider initial. The model list uses compact search and capability controls, quiet separators, and provider identities beside model names. Keep these styles scoped to Chat.
@ -58,7 +54,7 @@ Show available providers in a single-select dropdown beside model search. “All
| `--sidebar-accent-foreground` | `text-sidebar-accent-foreground` | Active nav item text |
| `--sidebar-border` | `border-sidebar-border` | Sidebar's own dividers/right border |
**Known gotcha:** generic `--accent`, `--secondary`, and `--muted` values may coincide in the dashboard theme. The standalone palette deliberately separates its sidebar selection from the chrome. Always use the sidebar-specific tokens rather than relying on incidental contrast from a generic token. Rules:
**Known gotcha:** generic `--accent`, `--secondary`, and `--muted` values may coincide in the dashboard theme. Always use the sidebar-specific tokens rather than relying on incidental contrast from a generic token. Rules:
- Sidebar container: `bg-sidebar`, never `bg-secondary` or `bg-accent`.
- Active/selected nav item: `bg-sidebar-accent text-sidebar-accent-foreground`, never `bg-accent` on its own inside the sidebar.
@ -81,7 +77,7 @@ Show available providers in a single-select dropdown beside model search. “All
// Page/greeting heading
<header className="moyai-chat-home-header">
<h1>What are we working on?</h1>
</header> // Moyai: 500 weight, clamp(32px, 3.4vw, 46px), 1.13 line-height
</header>
// Section heading (panel titles: "Your API Keys", "Your Usage")
<h2 className="text-base font-semibold tracking-tight">Your Usage</h2>
@ -113,7 +109,7 @@ Show available providers in a single-select dropdown beside model search. “All
| Context | Value |
| ---------------------------------- | ------------------------------------------- |
| Sidebar outer padding | `8px 10px 8px` |
| Sidebar outer padding | `p-3` |
| Nav item padding | `px-2.5 py-2` |
| Inline icon-to-label gap | `gap-2.5` in nav items, `gap-1.5` elsewhere |
| Section divider margin | `mx-2` |
@ -128,8 +124,8 @@ Show available providers in a single-select dropdown beside model search. “All
```tsx
rounded - md; // 8px — buttons, inputs, nav items, bordered divs
rounded - lg; // 10px — panel cards, table containers
rounded - xl; // 12px — shared controls (Moyai composer uses its source 10px rule)
rounded - lg; // 8px — Moyai user message bubbles
rounded - xl; // 12px — shared panels
rounded - lg; // 8px — user message bubbles
rounded - full; // pills, avatars, status dots
```
@ -161,198 +157,11 @@ Sizes: `default` (h-9), `sm` (h-8), `xs` (h-6), `lg` (h-10), `icon` (size-9), `i
### Sidebar nav item
No dedicated shadcn `Sidebar` block is installed (see gaps). Build nav items from `Button variant="ghost"`, full width, left-aligned, with the sidebar-specific active state:
Use `Sidebar`, `SidebarContent`, `SidebarFooter` and `SidebarMenuButton` from `components/shared/Sidebar`. Both workspaces use `SidebarBrand`, `ViewSwitcher` and `DashboardHeader`. Chat has a compact account menu with its existing identity, Settings and Sign out actions. Theme lives in the shared top bar. Gateway account preferences and admin controls stay in the gateway. Keep the shared 280px expanded and 72px collapsed widths, 56px header and 768px mobile breakpoint
```tsx
<Button
variant="ghost"
aria-current={active ? "page" : undefined}
className={`w-full justify-start gap-2.5 px-2.5 font-medium hover:bg-sidebar-accent ${
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground"
}`}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1 text-left">{label}</span>
</Button>
```
Use one New Chat action for the blank composer; do not duplicate it with a Chats destination. Chat retains Models, Integrations, Workspace and conversation history. Chat has no gateway-wide header search or worker selector. Search chats in the sidebar owns Cmd/Ctrl+K, including when the sidebar is collapsed or on mobile. Mobile navigation uses the same Sheet treatment as the dashboard
The desktop sidebar follows Moyai: 240px wide, 230px below 1250px, and 52px when collapsed. Its top row uses the shared `ViewSwitcher`, search and collapse controls above a transparent 32px New Chat button and compact destinations. The switcher exposes AI Gateway, Moyai, Chat and registered plugins, with the active workspace marked. Chat uses the same rounded speech-bubble icon in the trigger and menu, including the dashboard entry point. The collapsed rail keeps the icon-only switcher above its expand button. Workspace keeps credentials, API keys, logs, usage and preview feedback reachable in one menu. Recent conversation rows show title, model and date at 48px height. The footer shows account identity, appearance, settings and sign out. The shared dashboard Navbar is absent from this standalone surface; only its product switcher is reused.
At 600px and below, a compact header keeps the workspace switcher beside the labelled menu button, visible even when the navigation Sheet is closed. The same navigation renders once inside the existing `Sheet`. Choosing a route or conversation closes the Sheet. Do not render two concurrent conversation lists: they each own a search shortcut listener. The collapsed desktop list remains mounted for keyboard search, but is visually hidden. The Chat layout owns an authenticated `PluginModeProvider` so returning to AI Gateway clears a previously selected plugin.
The landing page reuses Moyai's left-aligned 720px home content, dot/grid backdrop, 46px heading, 132px editor and compact starter rows. `ChatModelPicker` sits at the right of the composer action row, matching Moyai's model selector position. Selecting a model chooses chat or image generation. Preserve LiteLLM's existing model access and request behavior; do not copy Moyai product or backend logic.
Configured organization branding appears above the account row in the expanded sidebar and mobile navigation. Light and dark variants fall back to the available image when only one is configured. Private filesystem images use the proxy image endpoint without exposing their paths. With no configured branding, the logo row is absent. The existing collapsed rail hides it along with the account row.
### ScrollArea
**Known gotcha — cost real debugging time:** a `ScrollArea` won't actually scroll if its container is sized with `max-height` instead of `height`, even though the box _looks_ correctly capped. Radix's `ScrollArea` viewport is `size-full` (`height: 100%`), and CSS percentage-height resolution requires the ancestor chain to have a genuinely _definite_ height — `max-height` on a flex container doesn't count as definite for this purpose, even though the browser renders the box at the capped size. The result: the viewport silently grows to its full content height instead of 100% of the visible box, `scrollHeight === clientHeight` is reported (no overflow, so no scrollbar), and scroll events fall through to whatever's behind the popover/panel instead of scrolling the list.
```tsx
// Wrong — looks capped, doesn't scroll (max-height isn't a definite height)
<div className="w-[280px] max-h-[400px] flex flex-col overflow-hidden">
<ScrollArea className="flex-1 min-h-0">{items}</ScrollArea>
</div>
// Right — explicit height propagates as definite through the whole chain
<div className="w-[280px] h-[400px] flex flex-col overflow-hidden">
<ScrollArea className="flex-1 h-0">{items}</ScrollArea>
</div>
```
If you need "shrink to fit content, cap at N px" rather than "always N px," you cannot get both with pure CSS here — pick the fixed height (the common case: any list that can realistically exceed the cap, e.g. a model picker with hundreds of entries) over one that only works when short.
### Input / Textarea
Use `Input` and `Textarea` from `@/components/ui`. Standalone Chat uses `MoyaiChatComposer` for the source editor and toolbar arrangement, preserving submission, cancellation, attachments and IME handling. Uploads share a limit of four files per message and 10 MB per file. Images retain previews; PDF, DOCX, XLSX, text and code files use named file chips and native Responses `input_file` parts. File support depends on the selected model and provider. Attachment bytes stay in the existing user-scoped IndexedDB store; history metadata stays small. Legacy DOC/XLS files are not accepted. Other chat surfaces continue using the shared `ChatComposer` / `InputGroupTextarea`; this visual port is scoped to standalone Chat.
```tsx
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
<div className="flex flex-col gap-1.5">
<Label htmlFor="alias">Key alias</Label>
<Input id="alias" disabled />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>;
```
Errors go **below** the field. Never a toast for form validation.
### Card
Use the installed Card primitives for grouped content and action reviews
```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
<Card size="sm">
<CardHeader>
<CardTitle>Review changes</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>;
```
### Messages and Markdown
`ChatMessages` owns the shared list renderer. `ChatMessageContent` renders one message without a list wrapper so a transcript scroller can own each row. Both retain user editing, reasoning, tool output, copying and response metrics
Use the official registry `Bubble` and `BubbleContent` primitives. User messages use the muted variant and end alignment. Assistant messages use the ghost variant at full available width. Keep paragraph spacing and list markers in the shared Markdown renderer. GFM tables use the shared Table primitives, which provide horizontal scrolling. Forward Markdown cell styles so column alignment survives rendering
LiteAdmin passes `allowImages={false}` to show image descriptions without automatically requesting external image URLs from administrative answers. Other chat consumers retain image rendering by default
Scrolling belongs to the conversation container. LiteAdmin uses official MessageScroller items with stable chronological IDs; a pending action and its result stay in the same row. Its inline action review is an explicit product exception to the AlertDialog confirmation pattern below
### Badge
```tsx
import { Badge } from "@/components/ui/badge"
<Badge variant="secondary">connected</Badge>
<Badge variant="outline">expiring</Badge>
<Badge variant="destructive">expired</Badge>
```
### Dialog / AlertDialog
```tsx
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Rotate key</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">{/* form */}</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Rotate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
```
Use `AlertDialog` (not a manual confirm) for any destructive action — delete conversation, revoke credential.
### Table
```tsx
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Key</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono text-xs">sk-...abcd</TableCell>
</TableRow>
</TableBody>
</Table>
</div>;
```
### Tabs
Always pass `variant="line"` for section tabs (Apps: All/Connected). The default variant renders a boxed pill with a background/shadow meant for compact toggle groups, not section tabs, and combining it with hand-added `border-b-2` classes produces a broken double-border look.
```tsx
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
<Tabs value={tab} onValueChange={setTab}>
<TabsList variant="line" className="w-full justify-start border-b rounded-none h-auto p-0">
<TabsTrigger value="all" className="rounded-none px-4 py-2 text-[13px]">
All
</TabsTrigger>
</TabsList>
</Tabs>;
```
### Loading states
Skeleton loaders while fetching — never a bare spinner or blank screen for list/table content. `Loader2` + `animate-spin` is reserved for **inside a button** during a submit action, not as a page/panel loading state.
```tsx
import { Skeleton } from "@/components/ui/skeleton";
<div className="flex flex-col gap-3">
<div className="border rounded-lg p-4 flex flex-col gap-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>;
```
### Toast / notifications
**Resolved:** `sonner` is installed and mounted from the root layout. Raise toasts through `toast` from `@/lib/toast`; don't introduce a second toast mechanism.
---
## Page Layout Patterns
### Chat shell (sidebar + routed content)
```tsx
<div className="chat-theme chat-workspace flex h-full w-full flex-col overflow-hidden text-foreground">
{/* optional full-width banner */}
<div className="flex flex-1 min-h-0 overflow-hidden">
<aside className="chat-glass-sidebar w-[248px] shrink-0 border-r border-sidebar-border flex flex-col overflow-hidden">
{/* Desktop nav; on mobile use Sheet with the same content instead. */}
</aside>
<main className="chat-glass-pane flex-1 flex flex-col overflow-hidden min-w-0">{children}</main>
</div>
</div>
```
The conversation title appears in the shared header. Do not repeat it in a second content header. Routed pages keep their content-specific headings and actions
### Routed panel (Integrations, Credentials, API Keys, Usage)
@ -391,14 +200,14 @@ No max-width cap on panel content — matches the rest of the dashboard's own pa
Card, Textarea and sonner are installed. Earlier versions of this document listed them as missing and encouraged parallel implementations; use the shared owners above
The shadcn Sidebar primitive block (`SidebarProvider`/`SidebarMenu`/etc) is still absent. Use the "Sidebar nav item" pattern above, built from `Button variant="ghost"` and the `sidebar-*` tokens
Shared sidebar primitives are available in `components/shared/Sidebar`. Use them instead of hand-writing a separate rail
## File Structure
```
src/components/chat/
ChatShell.tsx # Desktop navigation / mobile Sheet, shared across all /chat/* routes
ConversationList.tsx # Date-grouped chat list + Cmd+K search
ConversationList.tsx # Date-grouped chat list and conversation search
ChatMessages.tsx # Shared list and single-message rendering (user, assistant, tool)
MCPAppsPanel.tsx # Integrations grid + detail view
MCPConnectPicker.tsx # MCP server toggle popover

View file

@ -1,8 +1,7 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import MoyaiChatHome from "./MoyaiChatHome";
import type { Conversation } from "../types";
function homeProps(overrides: Partial<ComponentProps<typeof MoyaiChatHome>> = {}) {
return {
@ -11,24 +10,16 @@ function homeProps(overrides: Partial<ComponentProps<typeof MoyaiChatHome>> = {}
researchMode: false,
draft: "",
busy: false,
conversations: [],
onSelectConversation: vi.fn(),
onPrompt: vi.fn(),
...overrides,
};
}
function conversation(id: string, updatedAt: number): Conversation {
return { id, title: `Chat ${id}`, model: "chat-model", messages: [], mcpServerNames: [], createdAt: 1, updatedAt };
}
describe("MoyaiChatHome", () => {
it("should render the Moyai welcome and composer", () => {
it("should render the welcome and composer", () => {
render(<MoyaiChatHome {...homeProps()} />);
expect(screen.getByRole("heading", { level: 1, name: "What are we working on?" })).toBeInTheDocument();
expect(screen.getByText("Chat composer")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Recent chats" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("No recent chats in this browser yet.");
});
it("should render the research welcome when research mode is enabled", () => {
@ -36,26 +27,6 @@ describe("MoyaiChatHome", () => {
expect(screen.getByRole("heading", { level: 1, name: "What should we research?" })).toBeInTheDocument();
});
it("should show the three most recently updated chats and open the selected one", () => {
const conversations = [
conversation("first", 1),
conversation("second", 4),
conversation("third", 3),
conversation("fourth", 2),
];
const props = homeProps({ conversations });
render(<MoyaiChatHome {...props} />);
const recent = screen.getByRole("region", { name: "Recent chats" });
const buttons = within(recent).getAllByRole("button");
expect(buttons.map((button) => button.textContent)).toEqual(["Chat second", "Chat third", "Chat fourth"]);
expect(screen.queryByRole("button", { name: "Chat first" })).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Chat third" }));
});
expect(props.onSelectConversation).toHaveBeenCalledWith("third");
expect(conversations.map((item) => item.id)).toEqual(["first", "second", "third", "fourth"]);
});
it("should pass a starter prompt to the composer without submitting it", () => {
const props = homeProps();
render(<MoyaiChatHome {...props} />);
@ -63,7 +34,6 @@ describe("MoyaiChatHome", () => {
fireEvent.click(screen.getByRole("button", { name: /Review code/ }));
});
expect(props.onPrompt).toHaveBeenCalledWith("Review my changes for bugs and improvements.");
expect(props.onSelectConversation).not.toHaveBeenCalled();
});
it("should disable starters during a request", () => {

View file

@ -1,27 +1,23 @@
import type { ReactNode } from "react";
import type { ParseKeys } from "i18next";
import { ArrowUpRight, Bug, Clock3, Code2, ListChecks, type LucideIcon } from "lucide-react";
import { Bug, Code2, ListChecks, type LucideIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { Conversation } from "../types";
import "./moyai-chat.css";
const starters: { label: ParseKeys; detail: ParseKeys; prompt: ParseKeys; Icon: LucideIcon }[] = [
const starters: { label: ParseKeys; prompt: ParseKeys; Icon: LucideIcon }[] = [
{
label: "chat.moyaiHome.starters.review.label",
detail: "chat.moyaiHome.starters.review.detail",
prompt: "chat.moyaiHome.starters.review.prompt",
Icon: Code2,
},
{
label: "chat.moyaiHome.starters.fixBug.label",
detail: "chat.moyaiHome.starters.fixBug.detail",
prompt: "chat.moyaiHome.starters.fixBug.prompt",
Icon: Bug,
},
{
label: "chat.moyaiHome.starters.planFeature.label",
detail: "chat.moyaiHome.starters.planFeature.detail",
prompt: "chat.moyaiHome.starters.planFeature.prompt",
Icon: ListChecks,
},
@ -33,8 +29,6 @@ export default function MoyaiChatHome({
researchMode,
draft,
busy,
conversations,
onSelectConversation,
onPrompt,
}: {
children: ReactNode;
@ -42,15 +36,11 @@ export default function MoyaiChatHome({
researchMode: boolean;
draft: string;
busy: boolean;
conversations: Conversation[];
onSelectConversation: (id: string) => void;
onPrompt: (prompt: string) => void;
}) {
const { t } = useTranslation();
const recent = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3);
return (
<section className="moyai-chat-home">
<div className="moyai-chat-space-backdrop" aria-hidden="true" />
<div className="moyai-chat-home-content">
<header className="moyai-chat-home-header">
<h1>
@ -68,31 +58,6 @@ export default function MoyaiChatHome({
</h1>
</header>
{children}
<section className="moyai-chat-home-recent" aria-labelledby="recent-chats-heading">
<div className="moyai-chat-home-recent-header">
<h2 id="recent-chats-heading">{t("chat.moyaiHome.recentChats")}</h2>
</div>
{recent.length === 0 && (
<p className="text-muted-foreground" role="status">
{t("chat.moyaiHome.noRecentChats")}
</p>
)}
<ul>
{recent.map((conversation) => (
<li key={conversation.id}>
<Button
variant="ghost"
className="moyai-chat-home-recent-link"
onClick={() => onSelectConversation(conversation.id)}
>
<Clock3 className="size-3.5" />
<span>{conversation.title}</span>
<ArrowUpRight className="size-3.5" />
</Button>
</li>
))}
</ul>
</section>
{!imageMode && (
<div
className="moyai-chat-home-starters"
@ -100,18 +65,16 @@ export default function MoyaiChatHome({
aria-label={t("chat.moyaiHome.startersLabel")}
aria-hidden={Boolean(draft.trim())}
>
<p className="moyai-chat-home-starters-heading">{t("chat.moyaiHome.startersHeading")}</p>
{starters.map(({ label, detail, prompt, Icon }) => (
{starters.map(({ label, prompt, Icon }) => (
<Button
key={label}
variant="ghost"
variant="outline"
className="dark:bg-transparent"
disabled={busy || Boolean(draft.trim())}
onClick={() => onPrompt(t(prompt))}
>
<Icon />
<span className="moyai-chat-home-starter-label">{t(label)}</span>
<span className="moyai-chat-home-starter-detail">{t(detail)}</span>
<ArrowUpRight className="moyai-chat-home-starter-arrow" />
</Button>
))}
</div>

View file

@ -1,35 +1,15 @@
/* Adapted from BerriAI/moyai 1118ab0, native/web/home.css. MIT license in LICENSE.txt. */
@font-face {
font-family: "Moyai Geist";
src: url("./geist-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
.chat-theme {
--font-moyai-chat: "General Sans", "Moyai Geist", ui-sans-serif, system-ui, sans-serif;
font-family: var(--font-moyai-chat);
}
.moyai-models-page {
height: 100%;
min-height: 0;
overflow: auto;
padding: 52px 48px;
padding: 32px;
}
.moyai-models-content {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.moyai-models-header {
display: grid;
gap: 10px;
margin-bottom: 36px;
}
.moyai-models-header h1 {
font: 500 clamp(28px, 3vw, 38px) / 1.15 var(--font-moyai-chat, sans-serif);
letter-spacing: -0.04em;
}
.chat-model-hub-catalog {
min-width: 0;
}
@ -38,10 +18,8 @@
gap: 20px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
box-shadow: var(--chat-glass-shadow);
border-radius: 12px;
background: var(--background);
}
.chat-model-hub-catalog .chat-model-hub-filters {
gap: 12px;
@ -168,10 +146,7 @@
}
@media (max-width: 767px) {
.moyai-models-page {
padding: 64px 16px 28px;
}
.moyai-models-header {
margin-bottom: 24px;
padding: 24px 16px;
}
.chat-model-hub-card {
padding: 16px;
@ -196,12 +171,12 @@
position: relative;
isolation: isolate;
display: flex;
padding: 88px 48px 56px;
padding: 48px 32px;
}
.moyai-chat-home-content {
position: relative;
width: 100%;
max-width: 720px;
max-width: 760px;
margin: auto;
text-align: left;
}
@ -210,8 +185,8 @@
}
.moyai-chat-home-header h1 {
margin: 0;
font: 500 clamp(32px, 3.4vw, 46px) / 1.13 var(--font-moyai-chat, sans-serif);
letter-spacing: -0.045em;
font: 600 clamp(28px, 3vw, 36px) / 1.13 var(--font-sans);
letter-spacing: -0.035em;
text-wrap: balance;
}
.moyai-chat-home-form {
@ -226,13 +201,9 @@
font-size: 12px;
}
.moyai-chat-home-composer {
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
-webkit-backdrop-filter: var(--chat-glass-blur);
backdrop-filter: var(--chat-glass-blur);
background: var(--background);
border: 1px solid var(--chat-home-editor-border);
border-radius: 10px;
box-shadow: var(--chat-glass-shadow);
border-radius: 16px;
overflow: hidden;
}
.moyai-chat-home-composer:focus-within {
@ -243,27 +214,26 @@
position: relative;
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor {
min-height: 132px;
min-height: 112px;
padding: 21px 22px;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
font: 14px/1.8 var(--font-sans);
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor-placeholder {
top: 21px;
left: 22px;
right: 22px;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
font: 14px/1.8 var(--font-sans);
color: var(--muted-foreground);
}
.moyai-chat-home-composer textarea {
min-height: 132px;
min-height: 112px;
padding: 22px;
font-size: 15px;
resize: none;
}
.moyai-chat-home-composer .moyai-chat-composer-actions {
padding: 8px 12px;
border-top: 1px solid var(--border);
background: var(--chat-glass-chrome);
background: var(--background);
}
.moyai-chat-home-composer-tools {
display: flex;
@ -300,121 +270,27 @@
font: 10px/1.5 var(--ui-font-mono);
color: var(--foreground);
}
.moyai-chat-home-composer-hint {
display: flex;
justify-content: flex-end;
gap: 12px;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.5;
margin: 10px 0 0;
}
.moyai-chat-home-composer-hint [role="status"] {
margin-right: auto;
}
.moyai-chat-home-recent {
margin-top: 24px;
}
.moyai-chat-home-recent-header {
min-height: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.moyai-chat-home-recent h2 {
margin: 0;
font-size: 12px;
font-weight: 500;
}
.moyai-chat-home-recent-header button,
.moyai-chat-home-recent > p {
font-size: 11px;
}
.moyai-chat-home-recent ul {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.moyai-chat-home-recent .moyai-chat-home-recent-link {
display: flex;
justify-content: flex-start;
width: 100%;
min-width: 0;
height: 36px;
gap: 12px;
padding: 6px 10px;
font-size: 12px;
font-weight: 400;
}
.moyai-chat-home-recent-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moyai-chat-home-recent-link svg:last-child {
margin-left: auto;
}
.moyai-chat-home-starters {
display: grid;
gap: 2px;
margin-top: 30px;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.moyai-chat-home-starters[aria-hidden="true"] {
visibility: hidden;
}
.moyai-chat-home-starters-heading {
margin: 0 0 8px;
color: var(--foreground);
font-size: 11px;
line-height: 1.5;
}
.moyai-chat-home-starters > button {
display: flex;
justify-content: flex-start;
height: 36px;
min-height: 36px;
padding: 6px 10px;
gap: 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--foreground);
font-size: 12px;
border-radius: 999px;
color: var(--muted-foreground);
font-weight: 400;
}
.moyai-chat-home-starters > button:hover {
background: var(--muted);
border-color: var(--border);
color: var(--foreground);
}
.moyai-chat-home-starters > button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.moyai-chat-home-starters > button > svg {
width: 14px;
height: 14px;
color: var(--foreground);
}
.moyai-chat-home-starter-label {
color: var(--foreground);
}
.moyai-chat-home-starter-detail {
margin-left: auto;
font-size: 11px;
line-height: 1.5;
}
.moyai-chat-home-starters > button > .moyai-chat-home-starter-arrow {
color: var(--foreground);
width: 12px;
box-shadow: none;
}
@media (max-width: 1000px) {
.moyai-chat-home {
padding-inline: 28px;
}
.moyai-chat-home-header h1 {
font-size: 36px;
font-size: 30px;
}
}
@media (max-width: 600px) {
@ -428,7 +304,7 @@
margin-bottom: 24px;
}
.moyai-chat-home-header h1 {
font-size: 36px;
font-size: 30px;
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor {
min-height: 148px;
@ -453,26 +329,6 @@
max-width: 160px;
font-size: 12px !important;
}
.moyai-chat-home-composer-hint {
flex-wrap: wrap;
justify-content: flex-start;
}
.moyai-chat-home-recent .moyai-chat-home-recent-link {
min-height: 44px;
}
.moyai-chat-home-starters {
margin-top: 24px;
}
.moyai-chat-home-starters > button {
min-height: 44px;
gap: 8px;
}
.moyai-chat-home-starter-detail {
display: none;
}
.moyai-chat-home-starter-arrow {
margin-left: auto;
}
}
.moyai-chat-composer-actions {
@ -489,7 +345,7 @@
margin-left: auto;
}
.chat-theme .moyai-chat-editor {
min-height: 132px;
min-height: 112px;
max-height: 240px;
width: 100%;
border: 0;
@ -498,41 +354,17 @@
outline: none;
resize: none;
background: transparent;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
font: 14px/1.8 var(--font-sans);
padding: 21px 22px;
}
.chat-theme .moyai-chat-editor:focus-visible {
box-shadow: none;
outline: none;
}
.moyai-chat-space-backdrop {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(var(--moyai-home-dot) 1px, transparent 1px),
linear-gradient(var(--moyai-home-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--moyai-home-grid) 1px, transparent 1px);
background-size:
48px 48px,
192px 192px,
192px 192px;
background-position:
0 0,
24px 24px,
24px 24px;
mask-image: linear-gradient(#000, #0009);
}
.moyai-chat-home {
--moyai-home-dot: #5b60602b;
--moyai-home-grid: #5b606008;
min-height: 0;
flex: 1;
}
.dark .moyai-chat-home {
--moyai-home-dot: #aeb0b52e;
--moyai-home-grid: #aeb0b508;
}
@media (max-width: 600px) {
.chat-theme .moyai-chat-editor {
min-height: 148px;
@ -551,14 +383,14 @@
background: transparent;
border: 0;
box-shadow: none;
font: 10px/1.5 var(--font-moyai-mono, ui-monospace, monospace);
font: 13px/1.5 var(--font-sans);
color: var(--foreground);
}
.chat-theme .moyai-chat-editor::placeholder {
color: var(--chat-prompt-muted);
}
.chat-theme .moyai-chat-home-composer-tools button {
font-size: 11px;
font-size: 13px;
font-weight: 400;
}
@media (max-width: 600px) {
@ -569,16 +401,6 @@
}
}
@font-face {
font-family: "Moyai Geist Mono";
src: url("./geist-mono-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
.chat-theme {
--font-moyai-mono: "Moyai Geist Mono", ui-monospace, monospace;
}
.moyai-chat-home-composer .moyai-chat-send-actions > button {
width: 28px;
height: 28px;
@ -592,20 +414,6 @@
flex-direction: column;
min-height: 0;
}
.moyai-chat-conversation-header {
padding: 16px 28px;
min-height: 76px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
}
.moyai-chat-conversation-header h1 {
font-size: 18px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.moyai-chat-transcript-layout {
--chat-gutter: clamp(20px, 2.2vw, 32px);
flex: 1;
@ -636,10 +444,7 @@
position: relative;
border: 1px solid var(--chat-home-editor-border);
border-radius: 8px;
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
backdrop-filter: var(--chat-glass-blur);
box-shadow: var(--chat-glass-shadow);
background: var(--background);
}
.moyai-chat-conversation-composer .moyai-chat-composer-entry:focus-within {
border-color: var(--chat-focus-border);
@ -693,10 +498,6 @@
line-height: 1.7;
}
@media (max-width: 600px) {
.moyai-chat-conversation-header {
min-height: 52px;
padding: 12px 20px;
}
.moyai-chat-transcript-layout {
--chat-gutter: 16px;
}

View file

@ -1,21 +1,16 @@
import { SidebarBrand } from "@/components/shared/SidebarBrand";
import { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { getProxyBaseUrl } from "@/components/networking";
import { useTerminology } from "@/contexts/TerminologyContext";
import { useTheme } from "@/contexts/ThemeContext";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sidebar,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
@ -46,8 +41,6 @@ import {
LayoutGrid,
Network,
Palette,
PanelLeftClose,
PanelLeftOpen,
PiggyBank,
PlayCircle,
Route,
@ -89,14 +82,6 @@ import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
const ICON = { strokeWidth: 1.75 } as const;
const LOGO_CLASS_NAME =
"h-5 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:h-7 group-data-[collapsed=true]/sidebar:w-7";
function bundledLogoSrc(baseUrl: string, { dark, monogram }: { dark: boolean; monogram: boolean }): string {
const query = [dark && "theme=dark", monogram && "variant=monogram"].filter(Boolean).join("&");
return `${baseUrl}/get_image${query ? `?${query}` : ""}`;
}
interface SidebarProps {
collapsed?: boolean;
onToggleCollapsed?: () => void;
@ -554,13 +539,8 @@ const Sidebar_: React.FC<SidebarProps> = ({
const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(item.label) : item.label);
const isOrgAdmin = useIsOrgAdmin();
const { data: teams } = useTeams();
const { logoUrl, logoUrlDark } = useTheme();
const [erroredDarkLogo, setErroredDarkLogo] = useState<string | null>(null);
const { data: healthData } = useHealthReadinessDetails(accessToken);
const logout = useLogout(accessToken);
const baseUrl = getProxyBaseUrl();
const version = healthData?.litellm_version;
const currentRoute = routeForPathname(usePathname());
const selectedKey = currentRoute === "aria" ? null : findMenuItemKey(currentRoute);
@ -697,48 +677,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
);
};
const logoSrc = logoUrl || bundledLogoSrc(baseUrl, { dark: false, monogram: collapsed });
const reachableDarkLogo = logoUrlDark === erroredDarkLogo ? null : logoUrlDark;
const darkLogoSrc = reachableDarkLogo || logoUrl || bundledLogoSrc(baseUrl, { dark: true, monogram: collapsed });
return (
<Sidebar collapsed={collapsed}>
<SidebarHeader className="h-14 border-b border-border group-data-[collapsed=true]/sidebar:h-auto">
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
<div className="flex min-w-0 items-center gap-2">
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={darkLogoSrc}
alt=""
aria-hidden
onError={() => setErroredDarkLogo(logoUrlDark)}
className={cn(LOGO_CLASS_NAME, "hidden dark:block")}
/>
</Link>
{version && (
<Badge
variant="outline"
render={<a href="https://docs.litellm.ai/release_notes" target="_blank" rel="noopener noreferrer" />}
className="px-1.5 py-0 font-mono text-[10px] font-medium text-muted-foreground group-data-[collapsed=true]/sidebar:hidden"
>
v{version}
</Badge>
)}
</div>
{onToggleCollapsed && (
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="flex-none text-muted-foreground"
>
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</Button>
)}
</div>
</SidebarHeader>
<SidebarBrand accessToken={accessToken} collapsed={collapsed} onToggleCollapsed={onToggleCollapsed} />
<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" />

View file

@ -0,0 +1,87 @@
"use client";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import Link from "next/link";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { getProxyBaseUrl } from "@/components/networking";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SidebarHeader } from "@/components/shared/Sidebar";
import { uiHref } from "@/utils/uiHref";
import { cn } from "@/lib/cva.config";
const LOGO_CLASS_NAME =
"h-5 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:h-6 group-data-[collapsed=true]/sidebar:w-6";
function bundledLogoSrc(baseUrl: string, { dark, monogram }: { dark: boolean; monogram: boolean }): string {
const query = [dark && "theme=dark", monogram && "variant=monogram"].filter(Boolean).join("&");
return `${baseUrl}/get_image${query ? `?${query}` : ""}`;
}
export function SidebarBrand({
accessToken,
homeHref = uiHref(""),
homeLabel = "LiteLLM home",
collapsed = false,
onToggleCollapsed,
onHomeClick,
}: {
accessToken: string | null;
homeHref?: string;
homeLabel?: string;
collapsed?: boolean;
onToggleCollapsed?: () => void;
onHomeClick?: () => void;
}) {
const { t } = useTranslation();
const { logoUrl, logoUrlDark } = useTheme();
const [erroredDarkLogo, setErroredDarkLogo] = useState<string | null>(null);
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const baseUrl = getProxyBaseUrl();
const logoSrc = logoUrl || bundledLogoSrc(baseUrl, { dark: false, monogram: collapsed });
const reachableDarkLogo = logoUrlDark === erroredDarkLogo ? null : logoUrlDark;
const darkLogoSrc = reachableDarkLogo || logoUrl || bundledLogoSrc(baseUrl, { dark: true, monogram: collapsed });
return (
<SidebarHeader className="h-14 border-b border-border group-data-[collapsed=true]/sidebar:h-auto group-data-[collapsed=true]/sidebar:py-2">
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col group-data-[collapsed=true]/sidebar:gap-1">
<div className="flex min-w-0 items-center gap-2">
<Link href={homeHref} onClick={onHomeClick} className="flex min-w-0 items-center" aria-label={homeLabel}>
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={darkLogoSrc}
alt=""
aria-hidden
onError={() => setErroredDarkLogo(logoUrlDark)}
className={cn(LOGO_CLASS_NAME, "hidden dark:block")}
/>
</Link>
{version && (
<Badge
variant="outline"
render={<a href="https://docs.litellm.ai/release_notes" target="_blank" rel="noopener noreferrer" />}
className="px-1.5 py-0 font-mono text-[10px] font-medium text-muted-foreground group-data-[collapsed=true]/sidebar:hidden"
>
v{version}
</Badge>
)}
</div>
{onToggleCollapsed && (
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-label={collapsed ? t("shell.navbar.expandSidebar") : t("shell.navbar.collapseSidebar")}
className="flex-none text-muted-foreground group-data-[collapsed=true]/sidebar:size-9"
>
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</Button>
)}
</div>
</SidebarHeader>
);
}

View file

@ -4,6 +4,8 @@ import { useState, useCallback, useEffect } from "react";
import { switchToWorkerUrl, WorkerInfo } from "@/components/networking";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import { getLocalStorageItem } from "@/utils/localStorageUtils";
const SELECTED_WORKER_KEY = "litellm_selected_worker_id";
interface UseWorkerReturn {
@ -21,8 +23,7 @@ export const useWorker = (): UseWorkerReturn => {
const workers: WorkerInfo[] = uiConfig?.workers ?? [];
const [selectedWorkerId, setSelectedWorkerId] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(SELECTED_WORKER_KEY);
return getLocalStorageItem(SELECTED_WORKER_KEY);
});
// Once workers are loaded, restore proxyBaseUrl from the persisted selection

View file

@ -8040,7 +8040,7 @@
},
"placeholder": {
"conversation": "Send a message...",
"home": "Ask a question, attach a file, or paste something to work on…",
"home": "Ask anything…",
"research": "What should I research on the web?",
"image": "Describe the image you want to create…"
},
@ -8076,6 +8076,8 @@
},
"shell": {
"accountFallback": "Account",
"accountMenu": "Account menu, signed in as {{identity}}",
"home": "Chat home",
"workspaceItems": {
"credentials": "Credentials",
"apiKeys": "API Keys",
@ -8115,6 +8117,7 @@
},
"searchTitle": "Search conversations",
"searchLabel": "Search conversations",
"searchPlaceholder": "Search conversations…",
"noResults": "No conversations found",
"searchChats": "Search chats",
"recent": "Recent",