diff --git a/ui/app/_fallbacks/enterprise/components/api-keys/apiKeysIndexView.tsx b/ui/app/_fallbacks/enterprise/components/api-keys/apiKeysIndexView.tsx
index 7929d9bea0..8687f4490a 100644
--- a/ui/app/_fallbacks/enterprise/components/api-keys/apiKeysIndexView.tsx
+++ b/ui/app/_fallbacks/enterprise/components/api-keys/apiKeysIndexView.tsx
@@ -3,10 +3,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useGetCoreConfigQuery } from "@/lib/store";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Copy, InfoIcon, KeyRound } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
-import { toast } from "sonner";
import ContactUsView from "../views/contactUsView";
export default function APIKeysView() {
@@ -31,10 +31,7 @@ curl --location 'http://localhost:8080/v1/chat/completions'
]
}'`;
- const copyToClipboard = (text: string) => {
- navigator.clipboard.writeText(text);
- toast.success("Copied to clipboard");
- };
+ const { copy: copyToClipboard } = useCopyToClipboard();
if (isLoading) {
return
Loading...
;
diff --git a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx
index de1d5d712e..74669ec3ca 100644
--- a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx
+++ b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx
@@ -29,6 +29,7 @@ import {
StatusColors,
} from "@/lib/constants/logs";
import { LogEntry } from "@/lib/types/logs";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { ChevronDown, ChevronUp, Clipboard, Loader2, MoreVertical, Trash2 } from "lucide-react";
import moment from "moment";
import { toast } from "sonner";
@@ -82,6 +83,8 @@ const isContainerOperation = (object: string) => {
};
export function LogDetailSheet({ log, open, onOpenChange, handleDelete, onNavigate, hasPrev = false, hasNext = false }: LogDetailSheetProps) {
+ const { copy: copyRequestId } = useCopyToClipboard({ successMessage: "Request ID copied" });
+ const { copy: copyBody } = useCopyToClipboard({ successMessage: "Request body copied to clipboard", errorMessage: "Failed to copy request body" });
const [fetchLog, { data: fullLog, isFetching }] = useLazyGetLogByIdQuery();
useEffect(() => {
@@ -147,12 +150,7 @@ export function LogDetailSheet({ log, open, onOpenChange, handleDelete, onNaviga
Request ID:{" "}
{
- navigator.clipboard
- .writeText(displayLog.id)
- .then(() => toast.success("Request ID copied"))
- .catch(() => toast.error("Failed to copy"));
- }}
+ onClick={() => copyRequestId(displayLog.id)}
>
{displayLog.id}
@@ -177,22 +175,22 @@ export function LogDetailSheet({ log, open, onOpenChange, handleDelete, onNaviga
-
-
+
- copyRequestBody(displayLog)} data-testid="logdetails-copy-request-body-button">
+ copyRequestBody(displayLog, copyBody)} data-testid="logdetails-copy-request-body-button">
Copy request body
@@ -898,7 +896,7 @@ const normalizeObjectForCopy = (object: string | undefined): string => {
return mapping[normalized] ?? normalized;
};
-const copyRequestBody = async (log: LogEntry) => {
+const copyRequestBody = async (log: LogEntry, copy: (text: string) => Promise) => {
try {
// Check if request is for responses, chat, speech, text completion, or embedding (exclude transcriptions)
const object = normalizeObjectForCopy(log.object);
@@ -1007,14 +1005,7 @@ const copyRequestBody = async (log: LogEntry) => {
}
const requestBodyJson = JSON.stringify(requestBody, null, 2);
- navigator.clipboard
- .writeText(requestBodyJson)
- .then(() => {
- toast.success("Request body copied to clipboard");
- })
- .catch((error) => {
- toast.error("Failed to copy request body");
- });
+ await copy(requestBodyJson);
} catch (error) {
toast.error("Failed to copy request body");
}
diff --git a/ui/app/workspace/logs/views/collapsibleBox.tsx b/ui/app/workspace/logs/views/collapsibleBox.tsx
index dc1a8b41c3..09d8a109ac 100644
--- a/ui/app/workspace/logs/views/collapsibleBox.tsx
+++ b/ui/app/workspace/logs/views/collapsibleBox.tsx
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import { useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
interface CollapsibleBoxProps {
title: string;
@@ -15,6 +15,7 @@ export default function CollapsibleBox({ title, children, collapsedHeight = 60,
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const innerContentRef = useRef(null);
+ const { copy } = useCopyToClipboard();
useEffect(() => {
if (!innerContentRef.current) return;
@@ -39,15 +40,7 @@ export default function CollapsibleBox({ title, children, collapsedHeight = 60,
const handleCopy = () => {
if (!onCopy) return;
-
- navigator.clipboard
- .writeText(onCopy())
- .then(() => {
- toast.success("Copied to clipboard");
- })
- .catch(() => {
- toast.error("Failed to copy");
- });
+ copy(onCopy());
};
return (
diff --git a/ui/app/workspace/logs/views/emptyState.tsx b/ui/app/workspace/logs/views/emptyState.tsx
index 5ecb632f92..c4027ef4bc 100644
--- a/ui/app/workspace/logs/views/emptyState.tsx
+++ b/ui/app/workspace/logs/views/emptyState.tsx
@@ -6,9 +6,9 @@ import { CodeEditor } from "@/components/ui/codeEditor";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getExampleBaseUrl } from "@/lib/utils/port";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { AlertTriangle, Copy } from "lucide-react";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
type Provider = "openai" | "anthropic" | "genai" | "litellm" | "langchain";
type Language = "python" | "typescript";
@@ -42,10 +42,7 @@ interface CodeBlockProps {
}
function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = false, readonly = true }: CodeBlockProps) {
- const copyToClipboard = () => {
- navigator.clipboard.writeText(code);
- toast.success("Copied to clipboard");
- };
+ const { copy: copyToClipboard } = useCopyToClipboard();
return (
@@ -65,7 +62,7 @@ function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = fals
)}
-
+ copyToClipboard(code)}>
diff --git a/ui/app/workspace/mcp-logs/views/emptyState.tsx b/ui/app/workspace/mcp-logs/views/emptyState.tsx
index 45412fb4dd..a0250ad603 100644
--- a/ui/app/workspace/mcp-logs/views/emptyState.tsx
+++ b/ui/app/workspace/mcp-logs/views/emptyState.tsx
@@ -6,9 +6,9 @@ import { CodeEditor } from "@/components/ui/codeEditor";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getExampleBaseUrl } from "@/lib/utils/port";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { AlertTriangle, Copy } from "lucide-react";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
type Language = "python" | "typescript";
@@ -41,10 +41,7 @@ interface CodeBlockProps {
}
function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = false, readonly = true }: CodeBlockProps) {
- const copyToClipboard = () => {
- navigator.clipboard.writeText(code);
- toast.success("Copied to clipboard");
- };
+ const { copy: copyToClipboard } = useCopyToClipboard();
return (
@@ -64,7 +61,7 @@ function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = fals
)}
-
+ copyToClipboard(code)} aria-label="Copy to clipboard">
diff --git a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
index cc43f36e85..37bb027691 100644
--- a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
+++ b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
@@ -83,17 +83,17 @@ export function MCPLogDetailSheet({ log, open, onOpenChange, handleDelete, onNav
- onNavigate?.("prev")} aria-label="Previous log" data-testid="mcp-log-nav-prev">
+ onNavigate?.("prev")} aria-label="Previous log" data-testid="mcp-log-nav-prev" type="button">
- onNavigate?.("next")} aria-label="Next log" data-testid="mcp-log-nav-next">
+ onNavigate?.("next")} aria-label="Next log" data-testid="mcp-log-nav-next" type="button">
-
+
diff --git a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
index e5e36aedf2..1492536bd2 100644
--- a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
+++ b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
@@ -10,6 +10,7 @@ import { prometheusFormSchema, type PrometheusFormSchema } from "@/lib/types/sch
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { Switch } from "@/components/ui/switch";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { AlertTriangle, Copy, Eye, EyeOff, Info, Plus, Trash, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
@@ -44,7 +45,7 @@ export function PrometheusFormFragment({
const hasPrometheusAccess = useRbac(RbacResource.Observability, RbacOperation.Update);
const [showPassword, setShowPassword] = useState(false);
const [isSaving, setIsSaving] = useState(false);
- const [copied, setCopied] = useState(false);
+ const { copy, copied } = useCopyToClipboard();
const [showBasicAuth, setShowBasicAuth] = useState(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password));
const form = useForm({
@@ -86,9 +87,7 @@ export function PrometheusFormFragment({
const handleCopyEndpoint = () => {
if (metricsEndpoint) {
- navigator.clipboard.writeText(metricsEndpoint);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+ copy(metricsEndpoint);
}
};
diff --git a/ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx b/ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx
index 900c8128f4..eb87819741 100644
--- a/ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx
+++ b/ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx
@@ -177,7 +177,7 @@ export default function PluginSequenceSheet({ open, onClose, plugins }: PluginSe
Cancel
-
+
Save Sequence
diff --git a/ui/app/workspace/routing-rules/components/celBuilder/celRuleBuilder.tsx b/ui/app/workspace/routing-rules/components/celBuilder/celRuleBuilder.tsx
index 801e544e89..2ce19b2df5 100644
--- a/ui/app/workspace/routing-rules/components/celBuilder/celRuleBuilder.tsx
+++ b/ui/app/workspace/routing-rules/components/celBuilder/celRuleBuilder.tsx
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { getRoutingFields } from "@/lib/config/celFieldsRouting";
import { celOperatorsRouting } from "@/lib/config/celOperatorsRouting";
import { convertRuleGroupToCEL } from "@/lib/utils/celConverterRouting";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Field, QueryBuilder, RuleGroupType } from "react-querybuilder";
@@ -46,7 +47,7 @@ export function CELRuleBuilder({
}: CELRuleBuilderProps) {
const [query, setQuery] = useState(initialQuery || defaultQuery);
const [celExpression, setCelExpression] = useState("");
- const [copied, setCopied] = useState(false);
+ const { copy, copied } = useCopyToClipboard();
const onChangeRef = useRef(onChange);
// Keep ref updated so the query effect always invokes the latest callback
@@ -69,11 +70,7 @@ export function CELRuleBuilder({
onChangeRef.current?.(expression, query);
}, [query]);
- const handleCopy = async () => {
- await navigator.clipboard.writeText(celExpression);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
+ const handleCopy = () => copy(celExpression);
// Show loading state
if (isLoading) {
@@ -122,7 +119,7 @@ export function CELRuleBuilder({
-
+
{copied ? (
<>
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
index 349cf0c5ee..7caa410984 100644
--- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
+++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
@@ -22,6 +22,7 @@ import { resetDurationLabels } from "@/lib/constants/governance"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/utils/governance"
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import { ChevronLeft, ChevronRight, Copy, Edit, Eye, EyeOff, Plus, Search, Trash2 } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
@@ -136,10 +137,7 @@ export default function VirtualKeysTable({
return key.substring(0, 8) + "•".repeat(Math.max(0, key.length - 8));
};
- const copyToClipboard = (key: string) => {
- navigator.clipboard.writeText(key);
- toast.success("Copied to clipboard");
- };
+ const { copy: copyToClipboard } = useCopyToClipboard();
const hasActiveFilters = debouncedSearch || customerFilter || teamFilter;
diff --git a/ui/components/prompts/components/messagesView/assistantMessageView.tsx b/ui/components/prompts/components/messagesView/assistantMessageView.tsx
index 9577386d19..b9758f06d2 100644
--- a/ui/components/prompts/components/messagesView/assistantMessageView.tsx
+++ b/ui/components/prompts/components/messagesView/assistantMessageView.tsx
@@ -156,8 +156,10 @@ export function AssistantMessageView({
) : (
{
- if (!disabled && !isStreaming) setEditMode(true);
+ onClick={(e) => {
+ if (disabled || isStreaming || editMode) return;
+ if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
+ setEditMode(true);
}}
>
diff --git a/ui/components/prompts/components/messagesView/systemMessageView.tsx b/ui/components/prompts/components/messagesView/systemMessageView.tsx
index 6f632bc16c..e8a92ae423 100644
--- a/ui/components/prompts/components/messagesView/systemMessageView.tsx
+++ b/ui/components/prompts/components/messagesView/systemMessageView.tsx
@@ -119,7 +119,7 @@ export function SystemMessageView({
-
{ if (!disabled && !editMode) setEditMode(true); }} className={!disabled && !editMode ? "cursor-text" : ""}>
+
{ if (!disabled && !editMode && !(e.target as HTMLElement).closest("button, a, [role='button']")) setEditMode(true); }} className={!disabled && !editMode ? "cursor-text" : ""}>
{editMode ? (
{
- if (!disabled) setEditMode(true);
+ onClick={(e) => {
+ if (disabled || editMode) return;
+ if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
+ setEditMode(true);
}}
>
diff --git a/ui/components/prompts/components/messagesView/userMessageView.tsx b/ui/components/prompts/components/messagesView/userMessageView.tsx
index fc8da8965f..1513d9b6f1 100644
--- a/ui/components/prompts/components/messagesView/userMessageView.tsx
+++ b/ui/components/prompts/components/messagesView/userMessageView.tsx
@@ -286,8 +286,10 @@ export function UserMessageView({
) : (
{
- if (!disabled) setEditMode(true);
+ onClick={(e) => {
+ if (disabled || editMode) return;
+ if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
+ setEditMode(true);
}}
>
diff --git a/ui/components/ui/input.tsx b/ui/components/ui/input.tsx
index 40d5b0946a..37cba85dd9 100644
--- a/ui/components/ui/input.tsx
+++ b/ui/components/ui/input.tsx
@@ -2,9 +2,9 @@
import * as React from "react";
+import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { cn } from "@/lib/utils";
import { CopyIcon } from "lucide-react";
-import { toast } from "sonner";
import { Button } from "./button";
export interface InputProps extends React.InputHTMLAttributes
{
@@ -14,6 +14,8 @@ export interface InputProps extends React.InputHTMLAttributes
export const Input = React.forwardRef(
({ className, type, showCopyButton = false, inputClassName, ...props }, ref) => {
+ const { copy } = useCopyToClipboard();
+
if (showCopyButton) {
return (
(
variant="ghost"
size="icon"
onClick={() => {
- if (typeof props.value === "string") {
- navigator.clipboard.writeText(props.value as string);
- } else {
- navigator.clipboard.writeText(JSON.stringify(props.value));
- }
- toast.success("Copied to clipboard");
+ const text = typeof props.value === "string" ? props.value : JSON.stringify(props.value);
+ copy(text);
}}
>
diff --git a/ui/hooks/useCopyToClipboard.ts b/ui/hooks/useCopyToClipboard.ts
new file mode 100644
index 0000000000..ee22532f3f
--- /dev/null
+++ b/ui/hooks/useCopyToClipboard.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { toast } from "sonner";
+
+interface UseCopyToClipboardOptions {
+ successMessage?: string;
+ errorMessage?: string;
+ resetDelay?: number;
+}
+
+export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
+ const { successMessage = "Copied to clipboard", errorMessage = "Failed to copy", resetDelay = 2000 } = options;
+ const [copied, setCopied] = useState(false);
+ const timeoutRef = useRef>(undefined);
+
+ const copy = useCallback(
+ async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ toast.success(successMessage);
+
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setCopied(false), resetDelay);
+ } catch {
+ toast.error(errorMessage);
+ }
+ },
+ [successMessage, errorMessage, resetDelay],
+ );
+
+ return { copy, copied };
+}