fix(ui): copy from the log details drawer over plain HTTP

The request log drawer had its own copy handlers for the request id, x-litellm-call-id, trace or session id, input, output, metadata and realtime transcript. Each one called navigator.clipboard directly, so over plain HTTP they did nothing, and the input and output buttons could show a "copied" toast for a copy that never happened

Use the shared copyToClipboard helper in each of them, the same way CopyButton now does. The checkmark only shows when the copy succeeded

Fixes #48418
This commit is contained in:
Vivek Raj 2026-10-27 00:00:02 +05:30
parent ba746967c8
commit 534f9b515d
7 changed files with 40 additions and 39 deletions

View file

@ -1,7 +1,7 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "../../../../tests/test-utils";
import type { LogEntry } from "../types";
@ -118,3 +118,24 @@ describe("DrawerHeader back link", () => {
expect(screen.queryByRole("button", { name: /^Back to/ })).not.toBeInTheDocument();
});
});
describe("DrawerHeader copy", () => {
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(navigator, "clipboard");
Reflect.deleteProperty(document, "execCommand");
});
it("copies the request id when the browser has no clipboard API, as on a plain HTTP origin", async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
Object.defineProperty(document, "execCommand", { value: () => true, configurable: true });
const select = vi.spyOn(HTMLTextAreaElement.prototype, "select");
renderHeader(logEntry({}), false);
await user.click(screen.getByRole("button", { name: "Copy Request ID" }));
expect(select.mock.contexts.map((textArea) => textArea.value)).toEqual(["170d64ea-69f0-431a-be72-332f8f78c18a"]);
expect(await screen.findByRole("button", { name: "Copied!" })).toBeInTheDocument();
});
});

View file

@ -9,6 +9,7 @@ import { AutoRouterTag } from "@/components/shared/table_cells";
import { ClassifyTag } from "./ClassifyTag";
import { SidebarToggle } from "./SidebarToggle";
import { getProviderLogoAndName } from "../../provider_info_helpers";
import { copyToClipboard } from "@/utils/dataUtils";
import {
DRAWER_HEADER_PADDING,
COLOR_BORDER,
@ -183,13 +184,9 @@ function CopyableId({
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable in non-secure contexts */
}
if (!(await copyToClipboard(value))) return;
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
return (

View file

@ -4,7 +4,7 @@
*/
import { useState } from "react";
import { toast } from "@/lib/toast";
import { copyToClipboard } from "@/utils/dataUtils";
import { ParsedMessage } from "./prettyMessagesTypes";
import { SectionHeader } from "./SectionHeader";
import { CollapsibleMessage } from "./CollapsibleMessage";
@ -30,11 +30,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps)
const lastMessage = nonSystemMessages.length > 0 ? nonSystemMessages[nonSystemMessages.length - 1] : null;
const historyMessages = nonSystemMessages.slice(0, -1);
const handleCopy = () => {
const content = lastMessage?.content || "";
navigator.clipboard.writeText(content);
toast.success("Input copied");
};
const handleCopy = () => copyToClipboard(lastMessage?.content, "Input copied");
return (
<div

View file

@ -9,7 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { LogEntry } from "../types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
import { PROMPT_CACHE_CREATION_TOOLTIP, PROMPT_CACHE_READ_TOOLTIP } from "@/utils/promptCacheUsage";
import GuardrailViewer from "./sections/GuardrailViewer/GuardrailViewer";
import EvalViewer from "./sections/EvalViewer/EvalViewer";
@ -304,13 +304,9 @@ function CopyButton({
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(getText());
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable in non-secure contexts */
}
if (!(await copyToClipboard(getText()))) return;
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
return (

View file

@ -13,7 +13,7 @@ import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent";
import { sessionSpendLogsCall } from "../../networking";
import { useQuery } from "@tanstack/react-query";
import { getSpendString } from "@/utils/dataUtils";
import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
import { normalizeGuardrailEntries, sortSessionLogs, SessionLogSortMode } from "./utils";
import { DRAWER_WIDTH } from "./constants";
import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails";
@ -288,14 +288,9 @@ export function LogDetailsDrawer({
const leftPanelDisplayId = leftPanelId.length > 14 ? `${leftPanelId.slice(0, 11)}...` : leftPanelId;
const handleCopyLeftPanelId = async () => {
if (!leftPanelId) return;
try {
await navigator.clipboard.writeText(leftPanelId);
setCopiedLeftPanelId(true);
setTimeout(() => setCopiedLeftPanelId(false), 1200);
} catch {
/* clipboard unavailable in non-secure contexts */
}
if (!(await copyToClipboard(leftPanelId))) return;
setCopiedLeftPanelId(true);
setTimeout(() => setCopiedLeftPanelId(false), 1200);
};
if (!currentLog || !enrichedLog) return null;

View file

@ -4,7 +4,7 @@
*/
import { useState } from "react";
import { toast } from "@/lib/toast";
import { copyToClipboard } from "@/utils/dataUtils";
import { COLOR_BORDER } from "./constants";
import { ParsedMessage } from "./prettyMessagesTypes";
import { SectionHeader } from "./SectionHeader";
@ -19,12 +19,7 @@ interface OutputCardProps {
export function OutputCard({ message, completionTokens, outputCost }: OutputCardProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const handleCopy = () => {
if (!message) return;
navigator.clipboard.writeText(message.content || "");
toast.success("Output copied");
};
const handleCopy = () => copyToClipboard(message?.content, "Output copied");
return (
<div className="overflow-hidden rounded-md" style={{ border: `1px solid ${COLOR_BORDER}` }}>

View file

@ -8,6 +8,7 @@ import { useState } from "react";
import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { copyToClipboard } from "@/utils/dataUtils";
import { SectionHeader } from "./SectionHeader";
interface RealtimeEvent {
@ -276,7 +277,7 @@ function ConversationCard({
(r.output || []).flatMap((o) => (o.content || []).map((c) => `${o.role}: ${c.transcript || c.text || ""}`)),
)
.join("\n");
navigator.clipboard.writeText(transcripts);
copyToClipboard(transcripts);
};
return (