Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { LoadingOutlined } from "@ant-design/icons";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
import { Button, Dropdown, Space, Typography } from "antd";
import type { MenuProps } from "antd";
import React from "react";
Expand Down Expand Up @@ -74,9 +75,13 @@ export const BlogDropdown: React.FC = () => {
];
}

// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
return (
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
<Button type="text">Blog</Button>
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} !border-0 !bg-transparent`}>
Blog
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
</Button>
</Dropdown>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { Tooltip } from "antd";
import React from "react";

const iconBtnClass =
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer";

export const CommunityEngagementButtons: React.FC = () => {
const disableShowPrompts = useDisableShowPrompts();

// Hide buttons if prompts are disabled
if (disableShowPrompts) {
return null;
}

return (
<>
<Button
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
icon={<SlackOutlined />}
className="shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/50 transition-shadow"
>
Join Slack
</Button>
<Button
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className="shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/50 transition-shadow"
icon={<GithubOutlined />}
>
Star us on GitHub
</Button>
</>
<div
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
aria-label="Community links"
>
<Tooltip title="LiteLLM Slack community">
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
>
<SlackOutlined className="text-lg" />
</a>
</Tooltip>
<Tooltip title="LiteLLM on GitHub">
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
>
<GithubOutlined className="text-lg" />
</a>
</Tooltip>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { NotificationsBell, AGENT_PLATFORM_URL } from "./NotificationsBell";
import React from "react";
import userEvent from "@testing-library/user-event";

describe("NotificationsBell", () => {
beforeEach(() => {
localStorage.clear();
});

it("should open notifications with Agent Platform details and GitHub link", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.getByText(/LiteLLM Agent Platform/i)).toBeInTheDocument();
const githubBtn = screen.getByRole("link", { name: /^GitHub$/i });
expect(githubBtn).toHaveAttribute("href", AGENT_PLATFORM_URL);
expect(githubBtn).toHaveAttribute("target", "_blank");
expect(githubBtn).toHaveAttribute("rel", "noopener noreferrer");
});

it("should offer mark as read when announcement is unread", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.getByRole("button", { name: /^mark as read$/i })).toBeInTheDocument();
});

it("should hide mark as read and persist after marking read", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
expect(localStorage.getItem("litellmHideAgentPlatformBanner")).toBe("true");
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
});

it("should not show mark as read when previously dismissed", async () => {
localStorage.setItem("litellmHideAgentPlatformBanner", "true");
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { BellOutlined } from "@ant-design/icons";
import { Badge, Button, Popover, Typography } from "antd";
import React, { useEffect, useState } from "react";

const STORAGE_KEY = "litellmHideAgentPlatformBanner";
export const AGENT_PLATFORM_URL = "https://github.com/BerriAI/litellm-agent-platform";

export const NotificationsBell: React.FC = () => {
const [hasUnread, setHasUnread] = useState(false);
const [open, setOpen] = useState(false);

useEffect(() => {
try {
setHasUnread(localStorage.getItem(STORAGE_KEY) !== "true");
} catch {
setHasUnread(true);
}
}, []);

const markDismissed = () => {
try {
localStorage.setItem(STORAGE_KEY, "true");
} catch {
/* ignore */
}
setHasUnread(false);
setOpen(false);
};

const content = (
<div className="max-w-[280px]">
<Typography.Title level={5} className="!mt-0 !mb-2">
LiteLLM Agent Platform
</Typography.Title>
<Typography.Paragraph type="secondary" className="!mb-3 text-sm leading-snug">
Open-source agent infra — sandboxes, durable sessions, and workers on AWS Fargate.
</Typography.Paragraph>
<div className="flex flex-wrap items-center gap-2">
<Button type="primary" size="small" href={AGENT_PLATFORM_URL} target="_blank" rel="noopener noreferrer">
GitHub
</Button>
{hasUnread ? (
<Button type="link" size="small" className="!px-1" onClick={markDismissed}>
Mark as read
</Button>
) : null}
</div>
</div>
);

return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Button
type="text"
className="!flex !h-9 !w-9 items-center justify-center !rounded-md text-gray-600 transition-colors hover:!bg-gray-100 hover:!text-gray-900"
aria-label="Notifications"
>
<Badge dot={hasUnread} color="#1677ff" size="small" offset={[8, 2]}>
<BellOutlined className="text-base" aria-hidden />
</Badge>
</Button>
</Popover>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ vi.mock("@/utils/localStorageUtils", () => ({
describe("UserDropdown", () => {
const mockOnLogout = vi.fn();

const getAccountTrigger = () => screen.getByRole("button", { name: /account menu/i });

beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorizedImpl = () => ({
Expand All @@ -55,30 +57,31 @@ describe("UserDropdown", () => {

it("should render", () => {
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
expect(screen.getByRole("button")).toBeInTheDocument();
expect(getAccountTrigger()).toBeInTheDocument();
});

it("should display user button with User text", () => {
it("should surface initials and account menu affordance", () => {
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(getAccountTrigger()).toBeInTheDocument();
expect(screen.getByText("TE")).toBeInTheDocument();
});

it("should show user email when dropdown is opened", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});
});

it("should show user ID when dropdown is opened", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test-user-id")).toBeInTheDocument();
Expand All @@ -89,18 +92,18 @@ describe("UserDropdown", () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("Admin")).toBeInTheDocument();
expect(screen.getAllByText("Admin").length).toBeGreaterThan(0);
});
});

it("should display Standard badge for non-premium users", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("Standard")).toBeInTheDocument();
Expand All @@ -118,7 +121,7 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("Premium")).toBeInTheDocument();
Expand All @@ -129,10 +132,10 @@ describe("UserDropdown", () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

await user.click(screen.getByText("Logout"));
Expand All @@ -144,10 +147,10 @@ describe("UserDropdown", () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

const toggle = screen.getByLabelText("Toggle hide new feature indicators");
Expand All @@ -169,10 +172,10 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

const toggle = screen.getByLabelText("Toggle hide new feature indicators");
Expand All @@ -189,10 +192,10 @@ describe("UserDropdown", () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

const toggle = screen.getByLabelText("Toggle hide all prompts");
Expand All @@ -215,10 +218,10 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

const toggle = screen.getByLabelText("Toggle hide all prompts");
Expand All @@ -231,6 +234,17 @@ describe("UserDropdown", () => {
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
});

it("should show Account in the trigger when user id is the default placeholder", () => {
mockUseAuthorizedImpl = () => ({
userId: "default_user_id",
userEmail: null as any,
userRole: "Admin",
premiumUser: false,
});
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
expect(screen.getByText("Account")).toBeInTheDocument();
});

it("should display dash when user email is not available", async () => {
const user = userEvent.setup();
mockUseAuthorizedImpl = () => ({
Expand All @@ -242,7 +256,7 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("-")).toBeInTheDocument();
Expand All @@ -260,7 +274,7 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
const dashElements = screen.getAllByText("-");
Expand All @@ -277,10 +291,10 @@ describe("UserDropdown", () => {

renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);

await user.click(screen.getByText("User"));
await user.click(getAccountTrigger());

await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
});

const toggle = screen.getByLabelText("Toggle hide new feature indicators");
Expand Down
Loading
Loading