Fix copy button in Assistant embed (RND-11876)

Harden the code-block copy button so it works in the Assistant embed. The
async Clipboard API can be unavailable or reject in cross-origin iframe /
non-secure contexts, which threw an unhandled rejection and left the button
showing a false success. Fall back to execCommand('copy') and only show the
copied state when the write actually succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxDckjBnKCnQDGnuWufeKp
This commit is contained in:
Claude
2026-07-09 00:13:59 +00:00
parent 8e9a49de1a
commit 8fae712a4f
2 changed files with 62 additions and 5 deletions
@@ -6,7 +6,7 @@ import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getCodeTextFromId } from './utils';
import { copyToClipboard, getCodeTextFromId } from './utils';
/**
* Client component to copy the code of a code block.
@@ -32,15 +32,17 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
};
}, [copied]);
const onClick = () => {
const onClick = async () => {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
return;
}
navigator.clipboard.writeText(codeText);
setCopied(true);
// Only surface the "copied" state when the write actually succeeded, so a blocked
// clipboard in the embed no longer looks like a successful copy.
if (await copyToClipboard(codeText)) {
setCopied(true);
}
};
return (
@@ -75,6 +75,61 @@ export function getCodeTextFromId(codeId: string): string | null {
return element ? getCodeText(element) : null;
}
/**
* Copy text to the clipboard in a way that survives embed contexts.
*
* The async Clipboard API (`navigator.clipboard`) is unavailable or rejects in some embed
* setups — cross-origin iframes where the `clipboard-write` permission wasn't delegated, or
* non-secure contexts — which is why copying silently failed (and logged an error) in the
* Assistant embed. We fall back to a hidden `<textarea>` + `execCommand('copy')` so the button
* keeps working there. Returns whether the copy succeeded so callers only show a success state
* when the text actually reached the clipboard.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// The async API can reject in restricted embed contexts; fall back below.
}
}
return copyWithExecCommand(text);
}
/**
* Legacy clipboard write used as a fallback when the async Clipboard API is unavailable.
*
* `execCommand('copy')` still works in cross-origin iframes where the async API is blocked,
* which is why we keep it as the safety net for the embed.
*/
function copyWithExecCommand(text: string): boolean {
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
// Keep it out of view and avoid scrolling/zooming to it when focused.
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
try {
textarea.focus();
textarea.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
/**
* Compute the code text from the DOM,
* ignoring the empty white space we use for empty lines (represented with a class "ew").