From eb23dc2e8151d8e983816245d0379115ced1ee8e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 09:37:43 -0700 Subject: [PATCH 1/4] test(ui): pin SectionHeader and ToolsSection behaviour before migration --- .../LogDetailsDrawer/SectionHeader.test.tsx | 64 +++++++++++++++++ .../ToolsSection/ToolsSection.test.tsx | 69 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx new file mode 100644 index 00000000000..5aee6b33ec5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SectionHeader } from "./SectionHeader"; + +describe("SectionHeader", () => { + it("renders the input label with token, cost and turn metrics", () => { + render(); + + expect(screen.getByText("Input")).toBeInTheDocument(); + expect(screen.getByText("Tokens: 1,234")).toBeInTheDocument(); + expect(screen.getByText("Cost: $0.000123")).toBeInTheDocument(); + expect(screen.getByText("Turns: 3")).toBeInTheDocument(); + }); + + it("renders the output label", () => { + render(); + + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + + it("omits metrics that were not provided", () => { + render(); + + expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Cost:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument(); + }); + + it("omits the turn count when there are no turns", () => { + render(); + + expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument(); + }); + + it("copies without toggling the section", async () => { + const onCopy = vi.fn(); + const onToggleCollapse = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: /copy/i })); + + expect(onCopy).toHaveBeenCalledTimes(1); + expect(onToggleCollapse).not.toHaveBeenCalled(); + }); + + it("toggles the section when the header is clicked", async () => { + const onToggleCollapse = vi.fn(); + render(); + + await userEvent.click(screen.getByText("Input")); + + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + }); + + it("stays inert when no toggle handler is given", async () => { + const onCopy = vi.fn(); + render(); + + await userEvent.click(screen.getByText("Input")); + + expect(onCopy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx index 753a552b6db..2e9d83d9c6d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx @@ -2,10 +2,46 @@ * Core tests for Tools section */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { parseToolsFromLog } from "./utils"; +import { ToolsSection } from "./ToolsSection"; import { LogEntry } from "../columns"; +const logWithTools = (toolNames: string[], calledName?: string): LogEntry => ({ + request_id: "render-1", + api_key: "key", + team_id: "team", + model: "gpt-4", + model_id: "gpt-4", + call_type: "completion", + spend: 0.01, + total_tokens: 100, + prompt_tokens: 50, + completion_tokens: 50, + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + cache_hit: "none", + messages: JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: "hi" }], + tools: toolNames.map((name) => ({ + type: "function", + function: { name, description: `${name} description`, parameters: { type: "object", properties: {} } }, + })), + }), + response: JSON.stringify({ + choices: [ + { + message: calledName + ? { tool_calls: [{ id: "call_1", type: "function", function: { name: calledName, arguments: "{}" } }] } + : { content: "done" }, + }, + ], + }), +}); + describe("ToolsSection", () => { it("should parse tools from request and match with response tool calls", () => { const mockLog: LogEntry = { @@ -115,3 +151,36 @@ describe("ToolsSection", () => { expect(tools).toHaveLength(0); }); }); + +describe("ToolsSection rendering", () => { + it("summarises how many tools were provided and called", () => { + render(); + + expect(screen.getByText("Tools")).toBeInTheDocument(); + expect(screen.getByText("2 provided, 1 called")).toBeInTheDocument(); + }); + + it("previews the first two tool names", () => { + render(); + + expect(screen.getByText(/alpha, beta/)).toBeInTheDocument(); + }); + + it("renders nothing when the log has no tools", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("reveals the tool list only after the section is expanded", async () => { + render(); + + expect(screen.queryByText("called")).not.toBeInTheDocument(); + expect(screen.queryByText("not called")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("Tools")); + + expect(await screen.findByText("called")).toBeInTheDocument(); + expect(screen.getByText("not called")).toBeInTheDocument(); + }); +}); From bc9005c14980778a115639e599bd69e44d5bef71 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 09:39:37 -0700 Subject: [PATCH 2/4] refactor(ui): migrate SectionHeader and ToolsSection to shadcn --- ui/litellm-dashboard/eslint-suppressions.json | 12 +- .../LogDetailsDrawer/SectionHeader.tsx | 107 +++++++----------- .../view_logs/ToolsSection/ToolsSection.tsx | 62 +++++----- 3 files changed, 74 insertions(+), 107 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index a4dcf0f6c73..70fb98d36da 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -3848,11 +3848,6 @@ "count": 1 } }, - "src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.tsx": { "no-restricted-imports": { "count": 1 @@ -3893,11 +3888,6 @@ "count": 1 } }, - "src/components/view_logs/ToolsSection/ToolsSection.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/VectorStoreViewer.tsx": { "no-restricted-imports": { "count": 1 @@ -4028,4 +4018,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx index 817928f170c..93e9953b2ee 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx @@ -2,10 +2,10 @@ * SectionHeader - Datadog-style header with icon, label, metrics, and copy */ -import { Typography, Button, Tooltip } from "antd"; -import { MessageOutlined, CopyOutlined, DownOutlined, UpOutlined } from "@ant-design/icons"; - -const { Text } = Typography; +import { ChevronDown, ChevronUp, Copy, MessageSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; interface SectionHeaderProps { type: "input" | "output"; @@ -29,80 +29,57 @@ export function SectionHeader({ return (
{ - if (onToggleCollapse) { - e.currentTarget.style.background = "#f5f5f5"; - } - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = "#fafafa"; - }} + className={cn( + "flex items-center justify-between bg-muted px-4 py-2.5 transition-colors", + isCollapsed ? "border-b-0" : "border-b border-border", + onToggleCollapse ? "cursor-pointer hover:bg-accent" : "cursor-default", + )} > -
- {/* Collapse Arrow */} - {onToggleCollapse && ( -
- {isCollapsed ? ( - - ) : ( - - )} -
- )} - - {/* Icon + Label */} -
- {type === "input" ? ( - +
+ {onToggleCollapse && + (isCollapsed ? ( + ) : ( - ✨ + + ))} + +
+ {type === "input" ? ( + + ) : ( + ✨ )} - {type === "input" ? "Input" : "Output"} + {type === "input" ? "Input" : "Output"}
- {/* Tokens */} {tokens !== undefined && ( - - Tokens: {tokens.toLocaleString()} - + Tokens: {tokens.toLocaleString()} )} - {/* Cost */} - {cost !== undefined && ( - - Cost: ${cost.toFixed(6)} - - )} + {cost !== undefined && Cost: ${cost.toFixed(6)}} - {/* Turn count */} {turnCount !== undefined && turnCount > 0 && ( - - Turns: {turnCount} - + Turns: {turnCount} )}
- {/* Copy Button */} - -
); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx index 2a8cdd6a1ce..46c820caa25 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx @@ -3,18 +3,19 @@ * and indicates which ones were actually called in the response */ -import { Collapse, Typography } from "antd"; +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { LogEntry } from "../columns"; import { parseToolsFromLog } from "./utils"; import { ToolItem } from "./ToolItem"; -const { Text } = Typography; - interface ToolsSectionProps { log: LogEntry; } export function ToolsSection({ log }: ToolsSectionProps) { + const [open, setOpen] = useState(false); const tools = parseToolsFromLog(log); // Don't render if no tools @@ -32,34 +33,33 @@ export function ToolsSection({ log }: ToolsSectionProps) { const hasMoreTools = tools.length > 2; return ( -
- -

Tools

- - {totalTools} provided, {calledTools} called - - - • {toolNamePreview} - {hasMoreTools && "..."} - -
- ), - children: ( -
- {tools.map((tool) => ( - - ))} -
- ), - }, - ]} - /> +
+ + + {open ? ( + + ) : ( + + )} +
+

Tools

+ + {totalTools} provided, {calledTools} called + + + • {toolNamePreview} + {hasMoreTools && "..."} + +
+
+ +
+ {tools.map((tool) => ( + + ))} +
+
+
); } From b4092f476f0a43fcf39e3d549adcfb8228c89a96 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 12:47:45 -0700 Subject: [PATCH 3/4] test(ui): find the section copy button by role instead of the antd icon InputCard and OutputCard located SectionHeader's copy button by querying for a descendant with aria-label="copy", which is the antd CopyOutlined icon. That selector reaches into SectionHeader's internals, so migrating it off antd left copyButton undefined and failed four tests. getByRole("button", { name: /copy/i }) is green against both the antd and the shadcn SectionHeader, verified by running these two files against each. --- .../LogDetailsDrawer/InputCard.test.tsx | 14 +++----------- .../LogDetailsDrawer/OutputCard.test.tsx | 16 ++++------------ 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx index 4a6773994ed..5bf97b16233 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.test.tsx @@ -111,16 +111,12 @@ describe("InputCard", () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(copyButton!); + fireEvent.click(copyButton); }); await waitFor(() => { @@ -209,11 +205,7 @@ describe("InputCard", () => { }, ]; render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx index 6cf3cdd1670..94616e40495 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.test.tsx @@ -66,16 +66,12 @@ describe("OutputCard", () => { it("should copy message content when copy button is clicked", async () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(copyButton!); + fireEvent.click(copyButton); }); await waitFor(() => { @@ -88,14 +84,10 @@ describe("OutputCard", () => { render(); - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((button) => { - const icon = button.querySelector('[aria-label="copy"]'); - return icon !== null; - }); + const copyButton = screen.getByRole("button", { name: /copy/i }); expect(copyButton).toBeInTheDocument(); - await user.click(copyButton!); + await user.click(copyButton); await waitFor(() => { expect(mockWriteText).not.toHaveBeenCalled(); From dfdafbf89b8566c9db0ed9fc531b6f129e5148bb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 12:50:56 -0700 Subject: [PATCH 4/4] fix(ui): keep the tools panel mounted so a tool's expanded detail survives antd's Collapse kept the panel mounted once opened, so a tool a user had expanded stayed expanded after closing and reopening Tools. Base UI renders only the open branch, so the migration silently reset every ToolItem. The regression test passes against the antd original, fails against the migration without keepMounted, and passes with it. --- .../ToolsSection/ToolsSection.test.tsx | 25 +++++++++++++++---- .../view_logs/ToolsSection/ToolsSection.tsx | 2 +- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx index 2e9d83d9c6d..e146f38b42b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx @@ -2,7 +2,7 @@ * Core tests for Tools section */ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { parseToolsFromLog } from "./utils"; @@ -152,6 +152,8 @@ describe("ToolsSection", () => { }); }); +const isShown = (text: string) => screen.queryAllByText(text).some((el) => el.closest("[hidden]") === null); + describe("ToolsSection rendering", () => { it("summarises how many tools were provided and called", () => { render(); @@ -175,12 +177,25 @@ describe("ToolsSection rendering", () => { it("reveals the tool list only after the section is expanded", async () => { render(); - expect(screen.queryByText("called")).not.toBeInTheDocument(); - expect(screen.queryByText("not called")).not.toBeInTheDocument(); + expect(isShown("called")).toBe(false); + expect(isShown("not called")).toBe(false); await userEvent.click(screen.getByText("Tools")); - expect(await screen.findByText("called")).toBeInTheDocument(); - expect(screen.getByText("not called")).toBeInTheDocument(); + await waitFor(() => expect(isShown("called")).toBe(true)); + expect(isShown("not called")).toBe(true); + }); + + it("keeps a tool's expanded detail across a close and reopen", async () => { + render(); + + await userEvent.click(screen.getByText("Tools")); + await userEvent.click(await screen.findByText(/1\. get_weather/)); + expect(isShown("Description")).toBe(true); + + await userEvent.click(screen.getByText("Tools")); + await userEvent.click(screen.getByText("Tools")); + + await waitFor(() => expect(isShown("Description")).toBe(true)); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx index 46c820caa25..e3965f182fd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx @@ -52,7 +52,7 @@ export function ToolsSection({ log }: ToolsSectionProps) {
- +
{tools.map((tool) => (