From 5cd6347c2ce4874ad3051082a5365c25a5b708dd Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 20 Aug 2026 10:59:23 -0700 Subject: [PATCH] fix(ui): make inline styles and code blocks follow the theme (#37651) * fix(ui): make inline styles and code blocks follow the theme Two families of colour that a stylesheet never gets to see, so dark mode could not reach them. The log details drawer paints most of its chrome through React inline style objects holding raw hex: #f0f0f0 borders, #fafafa panels, #262626 body text, the antd-era role accents on message cards, and a green/red guardrail summary pill. Inline styles win over any class, so the drawer stayed light on a dark page. Every one of those literals becomes the var(--color-*) it was already imitating, which costs nothing in light mode and now tracks the theme. The guardrail pill keeps its layout inline and moves its three colours onto the success and destructive tokens the rest of the dashboard uses. The eleven code blocks pass a prism stylesheet as a prop, so the theme has to be picked in JavaScript. There is no dark-mode toggle in the app yet, only the `dark` class the design system keys off, so useIsDarkMode subscribes to that class through useSyncExternalStore and useSyntaxTheme swaps in oneDark when it is set. Each call site keeps the light stylesheet it already had, including the two that were relying on the prism default and now name it, so light mode is unchanged everywhere. Six of those call sites were casting the stylesheet to `any` or re-declaring its type to get past the prop signature; the hook returns the right type, so the casts are gone. * fix(ui): let the markdown code renderer keep its own syntax theme The three ReactMarkdown code renderers spread the remaining code element props after style, so the incoming style attribute widened the prop type and next build's type check rejected the hook's return value. The old `coy as any` cast hid the same conflict. Spreading first lets the explicit props win, which is what every one of these call sites meant. * test(ui): cover the dark-mode hooks that pick a syntax stylesheet useIsDarkMode carries the only real logic in this change: an external store over the root element's class list. Cover the three things that can regress, the class already being present at mount, the class being toggled later, and the observer being disconnected on unmount, then cover useSyntaxTheme handing back the caller's own stylesheet in light mode and oneDark in dark. The assertions are on which stylesheet object comes back, by identity, not on any colour it holds. * refactor(ui): drop the last stylesheet cast in the chat code renderer This was the one markdown code renderer still spreading the code element props over its style, so an incoming style attribute would have won over the theme, and the cast on the spread was what kept that compiling. Spreading first lets the theme win and the cast go. --- .../budgets/_components/budget_panel.tsx | 16 +++++-- .../chat_ui/ChatMessageBubble.test.tsx | 3 ++ .../components/chat_ui/ChatMessageBubble.tsx | 7 ++- .../playground/components/chat_ui/ChatUI.tsx | 5 +- .../chat_ui/CodeInterpreterOutput.tsx | 5 +- .../compareUI/components/MessageDisplay.tsx | 7 ++- .../prompt_editor_view/PromptCodeSnippets.tsx | 5 +- .../conversation_panel/MessageBubble.tsx | 7 ++- .../src/app/chat/page.integration.test.tsx | 2 +- .../src/components/AIHub/ModelHubTable.tsx | 8 +++- .../src/components/CodeBlock.tsx | 5 +- .../src/components/chat/ChatMessages.tsx | 11 ++--- .../components/chat_ui/ReasoningContent.tsx | 5 +- .../LogDetailsDrawer/DrawerHeader.tsx | 4 +- .../view_logs/LogDetailsDrawer/InputCard.tsx | 2 +- .../LogDetailsDrawer/LogDetailContent.tsx | 21 +++++++-- .../LogDetailsDrawer/RealtimePrettyView.tsx | 28 +++++------ .../view_logs/LogDetailsDrawer/constants.ts | 6 +-- .../LogDetailsDrawer/prettyMessagesUtils.ts | 16 +++---- .../src/hooks/useIsDarkMode.test.tsx | 42 +++++++++++++++++ .../src/hooks/useIsDarkMode.ts | 13 +++++ .../src/hooks/useSyntaxTheme.test.tsx | 47 +++++++++++++++++++ .../src/hooks/useSyntaxTheme.ts | 8 ++++ 23 files changed, 216 insertions(+), 57 deletions(-) create mode 100644 ui/litellm-dashboard/src/hooks/useIsDarkMode.test.tsx create mode 100644 ui/litellm-dashboard/src/hooks/useIsDarkMode.ts create mode 100644 ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx create mode 100644 ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index ad0f04f77aa..18b8e774aae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -6,6 +6,9 @@ import { Plus, Wallet } from "lucide-react"; import React, { useCallback, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader"; import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; import { Button } from "@/components/ui/button"; @@ -25,6 +28,7 @@ interface BudgetSettingsPageProps { } const BudgetPanel: React.FC = ({ accessToken }) => { + const syntaxTheme = useSyntaxTheme(prism); const [isCreateModelVisible, setIsCreateModelVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [selectedBudget, setSelectedBudget] = useState(null); @@ -150,13 +154,19 @@ const BudgetPanel: React.FC = ({ accessToken }) => { - {CREATE_END_USER_CURL_COMMAND} + + {CREATE_END_USER_CURL_COMMAND} + - {CHAT_COMPLETIONS_CURL_COMMAND} + + {CHAT_COMPLETIONS_CURL_COMMAND} + - {OPENAI_SDK_PYTHON_CODE} + + {OPENAI_SDK_PYTHON_CODE} + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.test.tsx index 647258b3d48..c218db5b914 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.test.tsx @@ -15,6 +15,9 @@ vi.mock("react-syntax-highlighter", () => ({ vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {}, + oneDark: {}, + oneLight: {}, + prism: {}, })); vi.mock("@/components/chat_ui/ReasoningContent", () => ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx index 2540c8f58b5..eb24463cfd3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx @@ -3,6 +3,8 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { CodeInterpreterResult } from "@/components/llm_calls/code_interpreter_handler"; import A2AMetrics from "./A2AMetrics"; import AudioRenderer from "./AudioRenderer"; @@ -38,6 +40,7 @@ function ChatMessageBubble({ codeInterpreterResult, accessToken, }: ChatMessageBubbleProps) { + const syntaxTheme = useSyntaxTheme(coy); const isUser = message.role === "user"; return ( @@ -143,13 +146,13 @@ function ChatMessageBubble({ const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( {String(children).replace(/\n$/, "")} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index 2dd3972a714..cc964b2833b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -21,6 +21,8 @@ import { import React, { useEffect, useMemo, useRef, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { v4 as uuidv4 } from "uuid"; import useCan from "@/app/(dashboard)/hooks/useCan"; import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; @@ -123,6 +125,7 @@ const ChatUI: React.FC = ({ simplified = false, fixedModel, }) => { + const syntaxTheme = useSyntaxTheme(coy); const canViewPolicies = useCan("viewPolicies"); const [mcpServers, setMCPServers] = useState([]); const [mcpToolsets, setMCPToolsets] = useState([]); @@ -2126,7 +2129,7 @@ const ChatUI: React.FC = ({ } + style={syntaxTheme} wrapLines={true} wrapLongLines={true} className="rounded-md" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx index 91972b5d770..1ddcec75173 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react"; import { Code, Download, FileImage, FileText, Loader2 } from "lucide-react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -33,6 +35,7 @@ function isImageFilename(filename: string | undefined): boolean { } const CodeInterpreterOutput: React.FC = ({ code, annotations = [], accessToken }) => { + const syntaxTheme = useSyntaxTheme(coy); const [imageUrls, setImageUrls] = useState>({}); const [loadingImages, setLoadingImages] = useState>({}); const [codeOpen, setCodeOpen] = useState(false); @@ -147,7 +150,7 @@ const CodeInterpreterOutput: React.FC = ({ code, ann
; } @@ -73,13 +76,13 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) { const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( {String(children).replace(/\n$/, "")} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx index 75a74fff6a1..af7d6421265 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx @@ -2,6 +2,8 @@ import React, { useState } from "react"; import { CodeIcon, CopyIcon } from "lucide-react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { toast } from "@/lib/toast"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -34,6 +36,7 @@ const PromptCodeSnippets: React.FC = ({ version = "1", proxySettings, }) => { + const syntaxTheme = useSyntaxTheme(coy); const [isModalVisible, setIsModalVisible] = useState(false); const [selectedLanguage, setSelectedLanguage] = useState<"curl" | "python" | "javascript">("curl"); const [selectedTab, setSelectedTab] = useState("basic"); @@ -296,7 +299,7 @@ main();`; = ({ message }) => { + const syntaxTheme = useSyntaxTheme(coy); return (
= ({ message }) => { const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( {String(children).replace(/\n$/, "")} diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx index 29267e522d9..ec884a90d7b 100644 --- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx @@ -35,7 +35,7 @@ vi.mock("react-syntax-highlighter", () => ({ Prism: ({ children }: { children: string }) =>
{children}
, })); -vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {} })); +vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {}, oneDark: {}, oneLight: {}, prism: {} })); vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: () => { diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index acfafb62f84..299104b271b 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -33,6 +33,9 @@ import { Copy, Inbox } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { checkTokenValidity } from "@/utils/jwtUtils"; import { getCookie } from "@/utils/cookieUtils"; @@ -58,6 +61,7 @@ function HubEmptyState({ title, body }: { title: string; body: string }) { } const ModelHubTable: React.FC = ({ accessToken, publicPage, premiumUser, userRole }) => { + const syntaxTheme = useSyntaxTheme(prism); // Admin Viewer follows the read-parity rule: see the AI Hub catalog, but // cannot toggle public visibility (write). const canModify = isProxyAdminRole(userRole || ""); @@ -736,7 +740,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Usage Example */}

Usage Example

- + {`import openai client = openai.OpenAI( @@ -1057,7 +1061,7 @@ print(response.choices[0].message.content)`} {/* Usage Example */}

Usage Example

- + {`from fastmcp import Client import asyncio diff --git a/ui/litellm-dashboard/src/components/CodeBlock.tsx b/ui/litellm-dashboard/src/components/CodeBlock.tsx index cc944f95901..2e5dd96eef7 100644 --- a/ui/litellm-dashboard/src/components/CodeBlock.tsx +++ b/ui/litellm-dashboard/src/components/CodeBlock.tsx @@ -3,12 +3,15 @@ import { CheckIcon, ClipboardIcon } from "lucide-react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; + interface CodeBlockProps { code: string; language: string; } const CodeBlock = ({ code, language }: CodeBlockProps) => { + const syntaxTheme = useSyntaxTheme(oneLight); const [copied, setCopied] = useState(false); const copyToClipboard = () => { navigator.clipboard.writeText(code); @@ -27,7 +30,7 @@ const CodeBlock = ({ code, language }: CodeBlockProps) => { & { node?: unknown }) { + const syntaxTheme = useSyntaxTheme(coy); const match = /language-(\w+)/.exec(className || ""); return match ? ( - } - language={match[1]} - PreTag="div" - className="rounded-md my-2" - {...(props as Record)} - > + {String(children).replace(/\n$/, "")} ) : ( diff --git a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx index c54b3180248..73c952eab3a 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx @@ -2,6 +2,8 @@ import React, { useState } from "react"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -11,6 +13,7 @@ interface ReasoningContentProps { } const ReasoningContent: React.FC = ({ reasoningContent }) => { + const syntaxTheme = useSyntaxTheme(coy); const [isExpanded, setIsExpanded] = useState(true); if (!reasoningContent) return null; @@ -59,7 +62,7 @@ const ReasoningContent: React.FC = ({ reasoningContent }) wrapLines={true} wrapLongLines={true} {...props} - style={coy as { [key: string]: React.CSSProperties }} + style={syntaxTheme} > {String(children).replace(/\n$/, "")} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index e80b447f402..798e786d69e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -189,13 +189,13 @@ function NavigationSection({ onClose: () => void; }) { const keyboardShortcutStyle = { - border: "1px solid #d9d9d9", + border: "1px solid var(--color-border)", borderRadius: 4, padding: "0 4px", fontSize: 12, fontFamily: "monospace", marginLeft: 4, - background: "#fafafa", + background: "var(--color-muted)", }; const splitStyle = { width: 1, height: 20, background: COLOR_BORDER }; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx index 5f4c623fae1..d367c69f263 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx @@ -39,7 +39,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) return (
-
Loading request & response data...
+
+ Loading request & response data... +
) : ( ) : ( -
+
Response data not available
)} @@ -631,6 +640,11 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
{allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""}{" "} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx index 5441bcbc4cf..82f0d3e7fb2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx @@ -106,10 +106,10 @@ export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProp {!sessionEvent && responseEvents.length === 0 && (
{ - e.currentTarget.style.background = "#f5f5f5"; + e.currentTarget.style.background = "var(--color-accent)"; }} onMouseLeave={(e) => { - e.currentTarget.style.background = "#fafafa"; + e.currentTarget.style.background = "var(--color-muted)"; }} >
@@ -236,11 +236,11 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou style={{ fontSize: 12, lineHeight: 1.6, - color: "#595959", - background: "#fafafa", + color: "var(--color-muted-foreground)", + background: "var(--color-muted)", padding: "8px 12px", borderRadius: 4, - border: "1px solid #f0f0f0", + border: "1px solid var(--color-border)", whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 120, @@ -282,7 +282,7 @@ function ConversationCard({ return (
{/* Turn header */} @@ -425,7 +425,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) { style={{ fontSize: 13, lineHeight: 1.7, - color: "#262626", + color: "var(--color-foreground)", whiteSpace: "pre-wrap", wordBreak: "break-word", }} @@ -484,7 +484,7 @@ function ConfigRow({ label, value }: { label: string; value: any }) { {label} -
{String(value)}
+
{String(value)}
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts index 67c274a6d29..2771b9b15fe 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts @@ -27,9 +27,9 @@ export const FONT_SIZE_MEDIUM = 13; export const FONT_SIZE_HEADER = 16; // Colors -export const COLOR_BORDER = "#f0f0f0"; -export const COLOR_BACKGROUND = "#fff"; -export const COLOR_BG_LIGHT = "#fafafa"; +export const COLOR_BORDER = "var(--color-border)"; +export const COLOR_BACKGROUND = "var(--color-background)"; +export const COLOR_BG_LIGHT = "var(--color-muted)"; // Spacing export const SPACING_SMALL = 4; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts index 1f73da1d30e..82ee081a7f8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts @@ -19,27 +19,27 @@ import { export const ROLE_STYLES: Record = { system: { background: "transparent", - borderColor: "#8c8c8c", + borderColor: "var(--color-muted-foreground)", label: "SYSTEM", - labelColor: "#8c8c8c", + labelColor: "var(--color-muted-foreground)", }, user: { background: "transparent", - borderColor: "#1677ff", + borderColor: "var(--color-info)", label: "USER", - labelColor: "#1677ff", + labelColor: "var(--color-info)", }, assistant: { background: "transparent", - borderColor: "#52c41a", + borderColor: "var(--color-success)", label: "ASSISTANT", - labelColor: "#52c41a", + labelColor: "var(--color-success)", }, tool: { background: "transparent", - borderColor: "#fa8c16", + borderColor: "var(--color-warning)", label: "TOOL RESULT", - labelColor: "#fa8c16", + labelColor: "var(--color-warning)", }, }; diff --git a/ui/litellm-dashboard/src/hooks/useIsDarkMode.test.tsx b/ui/litellm-dashboard/src/hooks/useIsDarkMode.test.tsx new file mode 100644 index 00000000000..c12beda4db4 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useIsDarkMode.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { useIsDarkMode } from "./useIsDarkMode"; + +beforeEach(() => { + document.documentElement.classList.remove("dark"); +}); + +afterAll(() => { + document.documentElement.classList.remove("dark"); +}); + +describe("useIsDarkMode", () => { + it("reports the dark class already on the root element at mount", () => { + document.documentElement.classList.add("dark"); + + const { result } = renderHook(() => useIsDarkMode()); + + expect(result.current).toBe(true); + }); + + it("follows the root element's dark class as it is toggled", async () => { + const { result } = renderHook(() => useIsDarkMode()); + expect(result.current).toBe(false); + + document.documentElement.classList.add("dark"); + await waitFor(() => expect(result.current).toBe(true)); + + document.documentElement.classList.remove("dark"); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("stops observing the root element once unmounted", () => { + const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect"); + + const { unmount } = renderHook(() => useIsDarkMode()); + unmount(); + + expect(disconnect).toHaveBeenCalled(); + disconnect.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts b/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts new file mode 100644 index 00000000000..bccaa31cb3a --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from "react"; + +const subscribe = (onStoreChange: () => void): (() => void) => { + const observer = new MutationObserver(onStoreChange); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); +}; + +const getSnapshot = (): boolean => document.documentElement.classList.contains("dark"); + +const getServerSnapshot = (): boolean => false; + +export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx new file mode 100644 index 00000000000..13b7d8136ef --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx @@ -0,0 +1,47 @@ +import { act, renderHook } from "@testing-library/react"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme"; + +const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } }; + +const setRootDark = async (enabled: boolean) => { + await act(async () => { + document.documentElement.classList.toggle("dark", enabled); + await Promise.resolve(); + }); +}; + +beforeEach(() => { + document.documentElement.classList.remove("dark"); +}); + +afterAll(() => { + document.documentElement.classList.remove("dark"); +}); + +describe("useSyntaxTheme", () => { + it("keeps the caller's own stylesheet in light mode", () => { + const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); + + expect(result.current).toBe(callerLightTheme); + }); + + it("swaps to oneDark when the root element turns dark", async () => { + const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); + + await setRootDark(true); + + expect(result.current).toBe(oneDark); + }); + + it("restores the caller's stylesheet when dark mode is turned back off", async () => { + document.documentElement.classList.add("dark"); + const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); + expect(result.current).toBe(oneDark); + + await setRootDark(false); + + expect(result.current).toBe(callerLightTheme); + }); +}); diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts new file mode 100644 index 00000000000..80f5b514751 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts @@ -0,0 +1,8 @@ +import type { CSSProperties } from "react"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { useIsDarkMode } from "./useIsDarkMode"; + +export type SyntaxTheme = Record; + +export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light);