diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index c109c92ead..aaaca0eeb3 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -8,6 +8,7 @@ import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; import { ActionBarMorePrimitive, @@ -38,7 +39,7 @@ import { RefreshCwIcon, SquareIcon, } from "lucide-react"; -import { type FC, useRef } from "react"; +import { type FC, useRef, useState } from "react"; export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ hideComposer, @@ -275,6 +276,32 @@ const AssistantMessage: FC = () => { ); }; +const COPY_RESET_MS = 2000; + +const CopyButton: FC = () => { + const aui = useAui(); + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + const handleCopy = () => { + const text = aui.message().getCopyText(); + if (copyToClipboard(text)) { + setCopied(true); + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_RESET_MS); + } + }; + + return ( + + {copied ? : } + + ); +}; + const AssistantActionBar: FC = () => { return ( { autohideFloat="single-branch" className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm" > - - - message.isCopied}> - - - !message.isCopied}> - - - - + @@ -352,16 +370,7 @@ const UserActionBar: FC = () => { autohide="not-last" className="aui-user-action-bar-root flex items-center" > - - - message.isCopied}> - - - !message.isCopied}> - - - - + diff --git a/studio/frontend/src/lib/copy-to-clipboard.ts b/studio/frontend/src/lib/copy-to-clipboard.ts new file mode 100644 index 0000000000..3ef3df1177 --- /dev/null +++ b/studio/frontend/src/lib/copy-to-clipboard.ts @@ -0,0 +1,44 @@ +/** + * Copy text to clipboard in a way that works on Mac/Safari. + * Uses a synchronous textarea + execCommand fallback so the copy runs in the + * same user gesture as the click (required by Safari's clipboard security). + */ +export function copyToClipboard(text: string): boolean { + if (typeof text !== "string" || text.length === 0) { + return false; + } + + // Synchronous fallback: works in Safari/Mac when clipboard API fails + // because it runs entirely within the user gesture (click) stack. + if (document.queryCommandSupported?.("copy") !== false) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + textarea.setAttribute("aria-hidden", "true"); + document.body.appendChild(textarea); + textarea.focus({ preventScroll: true }); + textarea.select(); + try { + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + document.body.removeChild(textarea); + return false; + } + } + + // Modern API only when fallback not available (e.g. non-browser) + if (typeof navigator?.clipboard?.writeText === "function") { + navigator.clipboard.writeText(text).then( + () => {}, + () => {} + ); + return true; + } + + return false; +}