Merge pull request #36793 from BerriAI/litellm_shadcn_logs_drawer_header_0813

refactor(ui): migrate SectionHeader and ToolsSection to shadcn
This commit is contained in:
yuneng-jiang 2026-08-13 13:41:36 -07:00 • committed by GitHub
commit 69b0296ca3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 228 additions and 129 deletions

View file

@ -3828,11 +3828,6 @@
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
"react-hooks/immutability": {
"count": 2
@ -3853,11 +3848,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

View file

@ -111,16 +111,12 @@ describe("InputCard", () => {
render(<InputCard messages={messages} />);
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(<InputCard messages={messages} />);
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();
});
});

View file

@ -66,16 +66,12 @@ describe("OutputCard", () => {
it("should copy message content when copy button is clicked", async () => {
render(<OutputCard message={mockMessage} />);
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(<OutputCard message={null} />);
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();

View file

@ -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(<SectionHeader type="input" tokens={1234} cost={0.000123} turnCount={3} onCopy={vi.fn()} />);
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(<SectionHeader type="output" onCopy={vi.fn()} />);
expect(screen.getByText("Output")).toBeInTheDocument();
});
it("omits metrics that were not provided", () => {
render(<SectionHeader type="input" onCopy={vi.fn()} />);
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(<SectionHeader type="input" turnCount={0} onCopy={vi.fn()} />);
expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument();
});
it("copies without toggling the section", async () => {
const onCopy = vi.fn();
const onToggleCollapse = vi.fn();
render(<SectionHeader type="input" onCopy={onCopy} onToggleCollapse={onToggleCollapse} />);
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(<SectionHeader type="input" onCopy={vi.fn()} onToggleCollapse={onToggleCollapse} />);
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(<SectionHeader type="input" onCopy={onCopy} />);
await userEvent.click(screen.getByText("Input"));
expect(onCopy).not.toHaveBeenCalled();
});
});

View file

@ -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 (
<div
onClick={onToggleCollapse}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
borderBottom: isCollapsed ? "none" : "1px solid #f0f0f0",
background: "#fafafa",
cursor: onToggleCollapse ? "pointer" : "default",
transition: "background 0.15s ease",
}}
onMouseEnter={(e) => {
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",
)}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
{/* Collapse Arrow */}
{onToggleCollapse && (
<div style={{ display: "flex", alignItems: "center" }}>
{isCollapsed ? (
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
) : (
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
)}
</div>
)}
{/* Icon + Label */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{type === "input" ? (
<MessageOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
<div className="flex items-center gap-4">
{onToggleCollapse &&
(isCollapsed ? (
<ChevronDown className="size-2.5 text-muted-foreground" />
) : (
<span style={{ fontSize: 14, filter: "grayscale(1)", opacity: 0.6 }}>✨</span>
<ChevronUp className="size-2.5 text-muted-foreground" />
))}
<div className="flex items-center gap-2">
{type === "input" ? (
<MessageSquare className="size-3.5 text-muted-foreground" />
) : (
<span className="text-sm opacity-60 grayscale">✨</span>
)}
<Text style={{ fontWeight: 500, fontSize: 14 }}>{type === "input" ? "Input" : "Output"}</Text>
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
</div>
{/* Tokens */}
{tokens !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Tokens: {tokens.toLocaleString()}
</Text>
<span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>
)}
{/* Cost */}
{cost !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Cost: ${cost.toFixed(6)}
</Text>
)}
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
{/* Turn count */}
{turnCount !== undefined && turnCount > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
Turns: {turnCount}
</Text>
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
)}
</div>
{/* Copy Button */}
<Tooltip title="Copy">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation(); // Prevent triggering collapse
onCopy();
}}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
/>
}
>
<Copy />
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</div>
);

View file

@ -2,10 +2,46 @@
* Core tests for Tools section
*/
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";
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,51 @@ describe("ToolsSection", () => {
expect(tools).toHaveLength(0);
});
});
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(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
expect(screen.getByText("Tools")).toBeInTheDocument();
expect(screen.getByText("2 provided, 1 called")).toBeInTheDocument();
});
it("previews the first two tool names", () => {
render(<ToolsSection log={logWithTools(["alpha", "beta", "gamma"])} />);
expect(screen.getByText(/alpha, beta/)).toBeInTheDocument();
});
it("renders nothing when the log has no tools", () => {
const { container } = render(<ToolsSection log={logWithTools([])} />);
expect(container).toBeEmptyDOMElement();
});
it("reveals the tool list only after the section is expanded", async () => {
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
expect(isShown("called")).toBe(false);
expect(isShown("not called")).toBe(false);
await userEvent.click(screen.getByText("Tools"));
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(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
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));
});
});

View file

@ -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 (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
<Text type="secondary" style={{ fontSize: 14 }}>
{totalTools} provided, {calledTools} called
</Text>
<Text type="secondary" style={{ fontSize: 14 }}>
• {toolNamePreview}
{hasMoreTools && "..."}
</Text>
</div>
),
children: (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{tools.map((tool) => (
<ToolItem key={tool.name} tool={tool} />
))}
</div>
),
},
]}
/>
<div className="mb-6 w-full max-w-full overflow-hidden rounded-lg bg-background shadow-sm">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-medium text-foreground">Tools</h3>
<span className="text-sm text-muted-foreground">
{totalTools} provided, {calledTools} called
</span>
<span className="text-sm text-muted-foreground">
• {toolNamePreview}
{hasMoreTools && "..."}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent keepMounted>
<div className="flex flex-col gap-2 px-4 pb-4">
{tools.map((tool) => (
<ToolItem key={tool.name} tool={tool} />
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}