From 0a5c61ffcc0675699280491c91d70367b06ed898 Mon Sep 17 00:00:00 2001 From: Konstantin Azizov Date: Mon, 20 Apr 2026 21:18:18 +0200 Subject: [PATCH] fix: prefer mainstream clipboard copy over deprecated one (#5109) Fixes #5097 Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- .../components/assistant-ui/markdown-text.tsx | 8 ++-- .../src/components/assistant-ui/reasoning.tsx | 4 +- .../src/components/assistant-ui/thread.tsx | 4 +- .../assistant-ui/tool-ui-python.tsx | 4 +- .../assistant-ui/tool-ui-terminal.tsx | 4 +- .../settings/components/api-key-row.tsx | 2 +- .../settings/components/key-reveal-card.tsx | 4 +- .../components/update-studio-instructions.tsx | 4 +- .../settings/components/usage-examples.tsx | 4 +- studio/frontend/src/lib/copy-to-clipboard.ts | 41 ++++--------------- 10 files changed, 26 insertions(+), 53 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index c7974db365..ea409591f0 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -272,8 +272,8 @@ function MermaidCopyButton({ source }: { source: string }) { type="button" className="absolute top-3.5 right-20 z-20 cursor-pointer text-muted-foreground transition-all hover:text-foreground" title="Copy Mermaid source" - onClick={() => { - if (!copyToClipboard(source)) { + onClick={async () => { + if (!(await copyToClipboard(source))) { return; } showCopied(); @@ -306,8 +306,8 @@ function CodeBlockActions({ className={ACTION_BUTTON_CLASS} title="Copy code" disabled={disabled} - onClick={() => { - if (!copyToClipboard(source)) { + onClick={async () => { + if (!(await copyToClipboard(source))) { return; } showCopied(); diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index e4306df4b9..4d46fc8d59 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -322,8 +322,8 @@ function ReasoningCopyButton({ startIndex, endIndex }: { startIndex: number; end .join("\n"); }); - const handleCopy = useCallback(() => { - if (copyToClipboard(reasoningText)) { + const handleCopy = useCallback(async () => { + if (await copyToClipboard(reasoningText)) { setCopied(true); if (resetRef.current) clearTimeout(resetRef.current); resetRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS); diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 77fdf28736..36713d665f 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -714,9 +714,9 @@ const CopyButton: FC = () => { const [copied, setCopied] = useState(false); const resetTimeoutRef = useRef | null>(null); - const handleCopy = () => { + const handleCopy = async () => { const text = aui.message().getCopyText(); - if (copyToClipboard(text)) { + if (await copyToClipboard(text)) { setCopied(true); if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = setTimeout(() => { diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index 6aa590ae11..bab735104c 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -44,8 +44,8 @@ function CopyBtn({ text }: { text: string }) { }; }, []); - const copy = useCallback(() => { - if (copyToClipboard(text)) { + const copy = useCallback(async () => { + if (await copyToClipboard(text)) { setCopied(true); if (timer.current) { clearTimeout(timer.current); diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx index f233f951d3..1b1357ab3c 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx @@ -34,8 +34,8 @@ function CopyBtn({ text }: { text: string }) { }; }, []); - const copy = useCallback(() => { - if (copyToClipboard(text)) { + const copy = useCallback(async () => { + if (await copyToClipboard(text)) { setCopied(true); if (timer.current) { clearTimeout(timer.current); diff --git a/studio/frontend/src/features/settings/components/api-key-row.tsx b/studio/frontend/src/features/settings/components/api-key-row.tsx index 74b4ba396e..f121045e1e 100644 --- a/studio/frontend/src/features/settings/components/api-key-row.tsx +++ b/studio/frontend/src/features/settings/components/api-key-row.tsx @@ -83,7 +83,7 @@ export function ApiKeyRow({ - copyToClipboard(prefix)}> + { await copyToClipboard(prefix); }}> Copy prefix diff --git a/studio/frontend/src/features/settings/components/key-reveal-card.tsx b/studio/frontend/src/features/settings/components/key-reveal-card.tsx index 667351570c..ab258537da 100644 --- a/studio/frontend/src/features/settings/components/key-reveal-card.tsx +++ b/studio/frontend/src/features/settings/components/key-reveal-card.tsx @@ -17,8 +17,8 @@ export function KeyRevealCard({ }) { const [copied, setCopied] = useState(false); - const handleCopy = () => { - if (copyToClipboard(rawKey)) { + const handleCopy = async () => { + if (await copyToClipboard(rawKey)) { setCopied(true); setTimeout(() => setCopied(false), 1800); } diff --git a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx index e97ef9507c..4d0b87981b 100644 --- a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx +++ b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx @@ -39,8 +39,8 @@ function CopyableCommand({ }; }, []); - const handleCopy = () => { - if (!copyToClipboard(command)) { + const handleCopy = async () => { + if (!(await copyToClipboard(command))) { return; } setCopied(true); diff --git a/studio/frontend/src/features/settings/components/usage-examples.tsx b/studio/frontend/src/features/settings/components/usage-examples.tsx index 603edb3984..a2dc2c3774 100644 --- a/studio/frontend/src/features/settings/components/usage-examples.tsx +++ b/studio/frontend/src/features/settings/components/usage-examples.tsx @@ -67,8 +67,8 @@ export function UsageExamples() { [], ); - const handleCopy = () => { - if (copyToClipboard(snippets[lang])) { + const handleCopy = async () => { + if (await copyToClipboard(snippets[lang])) { setCopied(true); setTimeout(() => setCopied(false), 1800); } diff --git a/studio/frontend/src/lib/copy-to-clipboard.ts b/studio/frontend/src/lib/copy-to-clipboard.ts index 7c97904d73..cce9ea7783 100644 --- a/studio/frontend/src/lib/copy-to-clipboard.ts +++ b/studio/frontend/src/lib/copy-to-clipboard.ts @@ -32,50 +32,23 @@ function copyWithExecCommand(text: string): boolean { } } -export function copyToClipboard(text: string): boolean { +export async function copyToClipboard(text: string): Promise { if (typeof text !== "string" || text.length === 0) { return false; } - if (typeof document !== "undefined" && document.queryCommandSupported?.("copy") !== false) { - if (copyWithExecCommand(text)) return true; - } - - // Async fallback for environments where execCommand is entirely unsupported - // but the Clipboard API is available (rare; kept for original contract parity). - if (typeof navigator?.clipboard?.writeText === "function") { - navigator.clipboard.writeText(text).then( - () => {}, - () => {}, - ); - return true; - } - - return false; -} - -export async function copyToClipboardAsync(text: string): Promise { - if (typeof text !== "string" || text.length === 0) { - return false; - } - - // Prefer the async Clipboard API: avoids focus disruption in Radix - // focus-trapped dialogs where execCommand always fails. + // Primary: async Clipboard API if (typeof navigator?.clipboard?.writeText === "function") { try { await navigator.clipboard.writeText(text); return true; - } catch { - // Clipboard API rejected (e.g. NotAllowedError, permission policy). - // User activation is still valid through promise chains per spec, so - // execCommand can succeed for callers outside focus-trapped dialogs. - // Inside a Radix modal the focus trap will block textarea.focus() and - // execCommand returns false harmlessly. - return copyWithExecCommand(text); + } catch (error) { + console.warn("Async clipboard API failed, falling back to execCommand", error); + // Clipboard API rejected (NotAllowedError, insecure context, etc.) + // Fall through to execCommand fallback. } } - // No Clipboard API (older browser / non-secure context): still in the - // original user-gesture frame, so execCommand can work. + // Fallback: execCommand (works in Safari when called during user gesture) return copyWithExecCommand(text); }