diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx index 80f9b3da47c..e030d393ab3 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { AttributesDetail } from "./AttributesDetail"; -import { CopyButton } from "../ui/CopyButton"; +import CopyButton from "@/components/shared/CopyButton"; import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent"; import { IdChip } from "../ui/IdChip"; import { PaneBar } from "../ui/PaneBar"; @@ -144,7 +144,7 @@ function SpanPane({ - +
{tokens > 0 && } @@ -209,7 +209,7 @@ function GroupPane({ )}
- + ); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx index cd3ea968d1a..f27023dd1e1 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx @@ -8,7 +8,7 @@ import remarkGfm from "remark-gfm"; import { cn } from "@/lib/cva.config"; import { FoldChevron } from "../ui/Collapse"; -import { CopyButton } from "../ui/CopyButton"; +import CopyButton from "@/components/shared/CopyButton"; import { displayValue, type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows"; import type { TraceMessage, TraceToolCall } from "../types"; @@ -94,7 +94,13 @@ export function ToolCallBlock({ call }: { call: TraceToolCall }) {
{call.name} - +
@@ -114,7 +120,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
setOpen((v) => !v)} /> {label} - +
{expanded && (
{name} - + {expandable ? (
{open && (
             
- +
- ); - - if (!iconOnly) return button; - return ( - - - - {copied ? copiedLabel : label} - - - ); -} diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx index 07e115d7a39..c2c2ee199b4 100644 --- a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx @@ -1,12 +1,31 @@ +import { act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { copyToClipboard } from "@/utils/dataUtils"; import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import CopyButton from "./CopyButton"; +vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() })); +vi.mock("lucide-react", async (importOriginal) => ({ + ...(await importOriginal()), + Copy: (props: ComponentProps<"svg">) => , + Check: (props: ComponentProps<"svg">) => , +})); + +const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + else Reflect.deleteProperty(navigator, "clipboard"); +}); + describe("CopyButton", () => { it("renders nothing when there is no value", () => { - const { container } = renderWithProviders(); - expect(container.querySelector("button")).toBeNull(); + renderWithProviders(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("shows the confirmation checkmark only after a successful write", async () => { @@ -17,12 +36,12 @@ describe("CopyButton", () => { renderWithProviders(); const button = screen.getByRole("button", { name: "Copy value" }); - expect(button.querySelector(".lucide-copy")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); await user.click(button); expect(writeText).toHaveBeenCalledWith("secret-value"); - await waitFor(() => expect(button.querySelector(".lucide-check")).toBeInTheDocument()); + expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument(); }); it("does not show the checkmark when the clipboard write is rejected", async () => { @@ -36,8 +55,8 @@ describe("CopyButton", () => { await user.click(button); await waitFor(() => expect(writeText).toHaveBeenCalledWith("secret-value")); - expect(button.querySelector(".lucide-check")).not.toBeInTheDocument(); - expect(button.querySelector(".lucide-copy")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); }); it("does not show the checkmark when the clipboard API is unavailable", async () => { @@ -49,7 +68,96 @@ describe("CopyButton", () => { const button = screen.getByRole("button", { name: "Copy value" }); await user.click(button); - expect(button.querySelector(".lucide-check")).not.toBeInTheDocument(); - expect(button.querySelector(".lucide-copy")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + + it("keeps the default icon styling, custom classes, title and 1200ms confirmation", async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + renderWithProviders( + , + ); + const button = screen.getByRole("button", { name: "Copy request ID" }); + expect(button).toHaveAttribute("title", "Copy request ID"); + expect(button).toHaveClass("hover:text-primary", "shrink-0"); + expect(button).not.toHaveTextContent(/.+/); + expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3"); + + await act(async () => fireEvent.click(button)); + expect(writeText).toHaveBeenCalledWith("request-id"); + expect(copyToClipboard).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1199)); + expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1)); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); + }); + + it("shows an outlined action with custom confirmation text for 1600ms", async () => { + vi.useFakeTimers(); + vi.mocked(copyToClipboard).mockResolvedValue(true); + renderWithProviders( + , + ); + const button = screen.getByRole("button", { name: "Copy step" }); + expect(button).toHaveTextContent("Copy step"); + expect(button).toHaveClass("border", "gap-1.5", "hover:text-foreground"); + expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3"); + + await act(async () => fireEvent.click(button)); + expect(copyToClipboard).toHaveBeenCalledWith("step-context", "Step copied"); + expect(button).toHaveTextContent("Step copied"); + expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1599)); + expect(button).toHaveTextContent("Step copied"); + act(() => vi.advanceTimersByTime(1)); + expect(button).toHaveTextContent("Copy step"); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); + }); + + it("shows a tooltip and no text for an icon-only action", async () => { + const user = userEvent.setup(); + vi.mocked(copyToClipboard).mockResolvedValue(true); + renderWithProviders(); + const button = screen.getByRole("button", { name: "Copy tool result" }); + expect(button).not.toHaveTextContent(/.+/); + expect(button).not.toHaveAttribute("title"); + await user.hover(button); + expect(await screen.findByText("Copy tool result")).toBeVisible(); + await user.click(button); + expect(copyToClipboard).toHaveBeenCalledWith("tool-result", "Copied"); + await user.unhover(button); + await user.hover(button); + expect(await screen.findByText("Copied")).toBeVisible(); + }); + + it("keeps the action label and copy icon when copying fails", async () => { + const user = userEvent.setup(); + vi.mocked(copyToClipboard).mockResolvedValue(false); + renderWithProviders(); + const button = screen.getByRole("button", { name: "Copy step" }); + await user.click(button); + expect(copyToClipboard).toHaveBeenCalledWith("step-context", "Copied"); + expect(button).toHaveTextContent("Copy step"); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument(); + }); + + it("retains an empty action while default empty values remain hidden", async () => { + const user = userEvent.setup(); + vi.mocked(copyToClipboard).mockResolvedValue(false); + renderWithProviders( + <> + + + , + ); + expect(screen.queryByRole("button", { name: "Copy value" })).not.toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Copy empty result" }); + await user.click(button); + expect(copyToClipboard).toHaveBeenCalledWith("", "Copied"); + expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx index 052d03df319..57ae86a26d0 100644 --- a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx +++ b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx @@ -1,5 +1,9 @@ +"use client"; + import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; import { Check, Copy } from "lucide-react"; import React, { useEffect, useState } from "react"; @@ -8,20 +12,52 @@ interface CopyButtonProps { label: string; className?: string; iconClassName?: string; + variant?: "icon" | "action"; + iconOnly?: boolean; + copiedLabel?: string; } -const CopyButton: React.FC = ({ value, label, className, iconClassName = "size-[15px]" }) => { +const VARIANTS = { + icon: { + resetMs: 1200, + iconClassName: "size-[15px]", + className: "text-muted-foreground hover:text-primary", + }, + action: { + resetMs: 1600, + iconClassName: "size-3", + className: "text-xs text-muted-foreground hover:text-foreground", + }, +} as const; + +const CopyButton: React.FC = ({ + value, + label, + className, + iconClassName, + variant = "icon", + iconOnly = false, + copiedLabel = "Copied", +}) => { const [copied, setCopied] = useState(false); + const isAction = variant === "action"; + const showLabel = isAction && !iconOnly; + const { resetMs, className: variantClassName, iconClassName: defaultIconClassName } = VARIANTS[variant]; + const iconSize = iconClassName ?? defaultIconClassName; useEffect(() => { if (!copied) return; - const timer = setTimeout(() => setCopied(false), 1200); + const timer = setTimeout(() => setCopied(false), resetMs); return () => clearTimeout(timer); - }, [copied]); + }, [copied, resetMs]); - if (!value) return null; + if (value == null || (!value && !isAction)) return null; const handleCopy = async () => { + if (isAction) { + setCopied(await copyToClipboard(value, copiedLabel)); + return; + } if (!navigator.clipboard) return; try { await navigator.clipboard.writeText(value); @@ -31,19 +67,30 @@ const CopyButton: React.FC = ({ value, label, className, iconCl } }; - return ( + const button = ( ); + + if (!isAction || !iconOnly) return button; + return ( + + + + {copied ? copiedLabel : label} + + + ); }; export default CopyButton;