refactor(ui): share CopyButton between Lens traces and logs (#44513)

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-04 10:00:49 -07:00 • committed by GitHub
parent 1459e00430
commit e1d16f51d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 197 additions and 90 deletions

View file

@ -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({
</TabsContent>
</Tabs>
<PaneFooter>
<CopyButton value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
<Meta label="time" value={fmtMs(span.duration_ms)} />
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
@ -209,7 +209,7 @@ function GroupPane({
)}
</div>
<PaneFooter>
<CopyButton value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
<CopyButton variant="action" value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
</PaneFooter>
</aside>
);

View file

@ -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 }) {
<div className="flex items-center gap-2 py-1">
<RoleTile role="tool" />
<span className="min-w-0 truncate text-sm font-medium">{call.name}</span>
<CopyButton value={call.name} label={`Copy ${call.name} name`} iconOnly className={INLINE_COPY} />
<CopyButton
variant="action"
value={call.name}
label={`Copy ${call.name} name`}
iconOnly
className={INLINE_COPY}
/>
</div>
<KeyValueRows entries={entries} />
</div>
@ -114,7 +120,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
<span className={LABEL}>{label}</span>
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
<CopyButton variant="action" value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
</div>
{expanded && (
<div
@ -146,7 +152,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
<RoleTile role="tool" failed={failed} />
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
<CopyButton value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
<CopyButton variant="action" value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
</span>
{expandable ? (
<button
@ -162,7 +168,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
) : (
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
)}
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
<CopyButton variant="action" value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
</div>
{open && (
<pre

View file

@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { LogDetailsDrawer } from "../../../logs/detail";
import { formatCost } from "../list/AgentTracesTable";
import { DetailGroup } from "./AttributesDetail";
import { CopyButton } from "../ui/CopyButton";
import CopyButton from "@/components/shared/CopyButton";
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
import type { Span } from "../types";
import { fmtMs, fmtTok } from "../utils";
@ -52,7 +52,7 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
<div className="flex flex-col gap-2.5">
<div className="flex min-w-0 items-center gap-1">
<KeyValueRows entries={[["request_id", span.litellm_request_id]]} mono className="min-w-0 flex-1" />
<CopyButton value={span.litellm_request_id} label="Copy request ID" iconOnly />
<CopyButton variant="action" value={span.litellm_request_id} label="Copy request ID" iconOnly />
</div>
<Button
variant="outline"

View file

@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { CopyButton } from "../ui/CopyButton";
import CopyButton from "@/components/shared/CopyButton";
import { useTracesApi } from "../api";
import { Button } from "@/components/ui/button";
import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation";
@ -144,7 +144,12 @@ function ConversationTool({ item }: { item: ConversationItem }) {
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Result</span>
<CopyButton value={item.toolResult ?? ""} label={`Copy ${item.span.name} result`} iconOnly />
<CopyButton
variant="action"
value={item.toolResult ?? ""}
label={`Copy ${item.span.name} result`}
iconOnly
/>
</div>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs leading-5">
{item.toolResult || "No output recorded"}

View file

@ -1,59 +0,0 @@
"use client";
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
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";
const COPIED_RESET_MS = 1600;
interface CopyButtonProps {
value: string;
label?: string;
copiedLabel?: string;
iconOnly?: boolean;
className?: string;
}
/** Copy → check for a moment. `iconOnly` renders a bare icon button with a tooltip. */
export function CopyButton({
value,
label = "Copy",
copiedLabel = "Copied",
iconOnly = false,
className,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
return () => window.clearTimeout(timeout);
}, [copied]);
const button = (
<Button
variant={iconOnly ? "ghost" : "outline"}
size={iconOnly ? "icon-xs" : "xs"}
className={cn("text-xs text-muted-foreground hover:text-foreground", !iconOnly && "gap-1.5", className)}
onClick={async () => setCopied(await copyToClipboard(value, copiedLabel))}
aria-label={label}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{!iconOnly && <span>{copied ? copiedLabel : label}</span>}
</Button>
);
if (!iconOnly) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>{copied ? copiedLabel : label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -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<typeof import("lucide-react")>()),
Copy: (props: ComponentProps<"svg">) => <svg {...props} role="img" aria-label="Copy icon" />,
Check: (props: ComponentProps<"svg">) => <svg {...props} role="img" aria-label="Copied icon" />,
}));
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(<CopyButton value={null} label="Copy value" />);
expect(container.querySelector("button")).toBeNull();
renderWithProviders(<CopyButton value={null} label="Copy value" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("shows the confirmation checkmark only after a successful write", async () => {
@ -17,12 +36,12 @@ describe("CopyButton", () => {
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
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(
<CopyButton value="request-id" label="Copy request ID" className="shrink-0" iconClassName="size-3" />,
);
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(
<CopyButton variant="action" value="step-context" label="Copy step" copiedLabel="Step copied" />,
);
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(<CopyButton variant="action" value="tool-result" label="Copy tool result" iconOnly />);
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(<CopyButton variant="action" value="step-context" label="Copy step" />);
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(
<>
<CopyButton value="" label="Copy value" />
<CopyButton variant="action" value="" label="Copy empty result" iconOnly />
</>,
);
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();
});
});

View file

@ -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<CopyButtonProps> = ({ 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<CopyButtonProps> = ({
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<CopyButtonProps> = ({ value, label, className, iconCl
}
};
return (
const button = (
<Button
type="button"
variant="ghost"
size="icon-xs"
variant={showLabel ? "outline" : "ghost"}
size={showLabel ? "xs" : "icon-xs"}
onClick={handleCopy}
aria-label={label}
title={label}
className={cn("text-muted-foreground hover:text-primary", className)}
title={isAction ? undefined : label}
className={cn(variantClassName, showLabel && "gap-1.5", className)}
>
{copied ? <Check className={iconClassName} /> : <Copy className={iconClassName} />}
{copied ? <Check className={iconSize} /> : <Copy className={iconSize} />}
{showLabel && <span>{copied ? copiedLabel : label}</span>}
</Button>
);
if (!isAction || !iconOnly) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>{copied ? copiedLabel : label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default CopyButton;