Merge pull request #19831 from BerriAI/litellm_ui_hide_send_feedback

[Feature] UI - Feedback Prompts: Option To Hide Prompts
This commit is contained in:
yuneng-jiang 2026-01-26 20:10:04 -08:00 committed by GitHub
commit a97bf452f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 230 additions and 9 deletions

View file

@ -0,0 +1,35 @@
// hooks/useDisableShowPrompts.ts
import { useSyncExternalStore } from "react";
import { getLocalStorageItem } from "@/utils/localStorageUtils";
import { LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils";
function subscribe(callback: () => void) {
const onStorage = (e: StorageEvent) => {
if (e.key === "disableShowPrompts") {
callback();
}
};
const onCustom = (e: Event) => {
const { key } = (e as CustomEvent).detail;
if (key === "disableShowPrompts") {
callback();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener(LOCAL_STORAGE_EVENT, onCustom);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom);
};
}
function getSnapshot() {
return getLocalStorageItem("disableShowPrompts") === "true";
}
export function useDisableShowPrompts() {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -16,6 +16,7 @@ vi.mock("@/utils/proxyUtils", () => ({
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
let mockUseHealthReadinessImpl = () => ({ data: null as any });
let mockGetLocalStorageItemImpl = () => null as string | null;
let mockUseDisableShowPromptsImpl = () => false;
vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => mockUseThemeImpl(),
@ -25,7 +26,12 @@ vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({
useHealthReadiness: () => mockUseHealthReadinessImpl(),
}));
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
}));
vi.mock("@/utils/localStorageUtils", () => ({
LOCAL_STORAGE_EVENT: "local-storage-change",
getLocalStorageItem: () => mockGetLocalStorageItemImpl(),
setLocalStorageItem: vi.fn(),
removeLocalStorageItem: vi.fn(),

View file

@ -1,4 +1,5 @@
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { getProxyBaseUrl } from "@/components/networking";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
@ -51,9 +52,9 @@ const Navbar: React.FC<NavbarProps> = ({
onToggleSidebar,
}) => {
const baseUrl = getProxyBaseUrl();
console.log("baseUrl", baseUrl);
const [logoutUrl, setLogoutUrl] = useState("");
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
const disableShowPrompts = useDisableShowPrompts();
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadiness();
const version = healthData?.litellm_version;
@ -152,6 +153,27 @@ const Navbar: React.FC<NavbarProps> = ({
aria-label="Toggle hide new feature indicators"
/>
</div>
<div
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
onClick={(e) => e.stopPropagation()}
>
<span className="text-gray-500 text-xs">Hide All Prompts</span>
<Switch
className="ml-auto"
size="small"
checked={disableShowPrompts}
onChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
} else {
removeLocalStorageItem("disableShowPrompts");
emitLocalStorageChange("disableShowPrompts");
}
}}
aria-label="Toggle hide all prompts"
/>
</div>
</div>
</div>
),

View file

@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import { MessageSquare } from "lucide-react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NudgePrompt } from "./NudgePrompt";
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
useDisableShowPrompts: vi.fn(),
}));
vi.mock("@/utils/localStorageUtils", () => ({
setLocalStorageItem: vi.fn(),
emitLocalStorageChange: vi.fn(),
LOCAL_STORAGE_EVENT: "local-storage-change",
}));
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
const mockUseDisableShowPrompts = vi.mocked(useDisableShowPrompts);
const mockSetLocalStorageItem = vi.mocked(setLocalStorageItem);
const mockEmitLocalStorageChange = vi.mocked(emitLocalStorageChange);
const defaultProps = {
onOpen: vi.fn(),
onDismiss: vi.fn(),
isVisible: true,
title: "Test Title",
description: "Test Description",
buttonText: "Open Modal",
icon: MessageSquare,
accentColor: "#3b82f6",
};
describe("NudgePrompt", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseDisableShowPrompts.mockReturnValue(false);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should render", () => {
render(<NudgePrompt {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render with all provided props", () => {
const { container } = render(<NudgePrompt {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open Modal" })).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
});
it("should not render when isVisible is false", () => {
render(<NudgePrompt {...defaultProps} isVisible={false} />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
it("should not render when disableShowPrompts is true", () => {
mockUseDisableShowPrompts.mockReturnValue(true);
render(<NudgePrompt {...defaultProps} />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
it("should display progress bar with correct accent color", () => {
const { container } = render(<NudgePrompt {...defaultProps} accentColor="#ff0000" />);
const progressBar = container.querySelector("div[style*='width']");
expect(progressBar).toHaveStyle({ backgroundColor: "#ff0000" });
});
it("should reset progress when isVisible becomes false", () => {
const { rerender, container } = render(<NudgePrompt {...defaultProps} />);
vi.advanceTimersByTime(5000);
rerender(<NudgePrompt {...defaultProps} isVisible={false} />);
rerender(<NudgePrompt {...defaultProps} isVisible={true} />);
const progressBar = container.querySelector("div[style*='width']");
expect(progressBar?.getAttribute("style")).toContain("width: 100%");
});
it("should apply custom button style when provided", () => {
const buttonStyle = { backgroundColor: "#custom-color" };
render(<NudgePrompt {...defaultProps} buttonStyle={buttonStyle} />);
const openButton = screen.getByRole("button", { name: "Open Modal" });
expect(openButton).toHaveStyle(buttonStyle);
});
});

View file

@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import { X, LucideIcon } from "lucide-react";
import { X, LucideIcon, Check } from "lucide-react";
import { Button } from "antd";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { setLocalStorageItem, emitLocalStorageChange } from "@/utils/localStorageUtils";
interface NudgePromptProps {
onOpen: () => void;
@ -15,6 +17,7 @@ interface NudgePromptProps {
}
const DISMISS_DURATION = 15000; // 15 seconds
const CONFIRMATION_DURATION = 5000; // 5 seconds
export function NudgePrompt({
onOpen,
@ -27,11 +30,14 @@ export function NudgePrompt({
accentColor,
buttonStyle,
}: NudgePromptProps) {
const disableShowPrompts = useDisableShowPrompts();
const [progress, setProgress] = useState(100);
const [showConfirmation, setShowConfirmation] = useState(false);
useEffect(() => {
if (!isVisible) {
setProgress(100);
setShowConfirmation(false);
return;
}
@ -49,13 +55,53 @@ export function NudgePrompt({
return () => clearInterval(interval);
}, [isVisible]);
if (!isVisible) return null;
useEffect(() => {
if (showConfirmation) {
const timer = setTimeout(() => {
setShowConfirmation(false);
onDismiss();
}, CONFIRMATION_DURATION);
return () => clearTimeout(timer);
}
}, [showConfirmation, onDismiss]);
const handleDontAskAgain = () => {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
setShowConfirmation(true);
};
// Show confirmation even if disableShowPrompts is true (since we just set it)
if (showConfirmation) {
return (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
<div className="p-4">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-700 font-medium">
Got it, we will not ask again. Reactivate this at any time in the User Menu.
</p>
</div>
</div>
</div>
</div>
);
}
// Don't show the prompt if disabled (unless we're showing confirmation)
if (!isVisible || disableShowPrompts) return null;
return (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${
isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
{/* Progress bar at top showing time remaining */}
<div className="h-1 bg-gray-100 w-full">
@ -81,9 +127,20 @@ export function NudgePrompt({
<p className="text-sm text-gray-600 mb-3">{description}</p>
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
{buttonText}
</Button>
<div className="space-y-2">
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
{buttonText}
</Button>
<Button
variant="outlined"
danger
block
onClick={handleDontAskAgain}
className="text-xs"
>
Don&apos;t ask me again
</Button>
</div>
</div>
</div>
);