diff --git a/packages/go/web/components/chat/chat-message.tsx b/packages/go/web/components/chat/chat-message.tsx index 5b678ef0a..0221a80f4 100644 --- a/packages/go/web/components/chat/chat-message.tsx +++ b/packages/go/web/components/chat/chat-message.tsx @@ -12,6 +12,7 @@ import { A2UIRenderer } from '@/components/a2ui/a2ui-renderer'; import { workspaceApi } from '@/lib/api'; import { useLayout } from '@/components/layout/layout-context'; import { useWorkspace } from '@/lib/workspace-context'; +import { copyTextToClipboard } from '@/lib/clipboard'; interface Attachment { fileId: string; @@ -129,7 +130,7 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [], onA const handleCopy = async () => { try { - await navigator.clipboard.writeText(message.content); + await copyTextToClipboard(message.content); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { diff --git a/packages/go/web/components/layout/workspace-switcher-menu.tsx b/packages/go/web/components/layout/workspace-switcher-menu.tsx index 3d3c68338..2098d309a 100644 --- a/packages/go/web/components/layout/workspace-switcher-menu.tsx +++ b/packages/go/web/components/layout/workspace-switcher-menu.tsx @@ -40,6 +40,7 @@ import { Switch } from '@/components/ui/switch'; import { useWorkspace } from '@/lib/workspace-context'; import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { WorkspaceHistory, parseWorkspaceURL, @@ -150,15 +151,19 @@ function WorkspaceSelectorDialog({ connectTo(parsed.workspaceId, parsed.token); }; - const handleCopyToken = () => { + const handleCopyToken = async () => { if (!token) { toast.error('No workspace token available'); return; } - navigator.clipboard.writeText(token); - setTokenCopied(true); - toast.success('Workspace token copied'); - setTimeout(() => setTokenCopied(false), 1500); + try { + await copyTextToClipboard(token); + setTokenCopied(true); + toast.success('Workspace token copied'); + setTimeout(() => setTokenCopied(false), 1500); + } catch { + toast.error('Failed to copy workspace token'); + } }; // Top three recents, excluding the current workspace (Swift renders diff --git a/packages/go/web/hooks/use-copy-to-clipboard.ts b/packages/go/web/hooks/use-copy-to-clipboard.ts index 0e1e9e740..eb1924b74 100644 --- a/packages/go/web/hooks/use-copy-to-clipboard.ts +++ b/packages/go/web/hooks/use-copy-to-clipboard.ts @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { copyTextToClipboard as copyText } from '@/lib/clipboard'; export function useCopyToClipboard({ timeout = 2000, @@ -12,13 +13,9 @@ export function useCopyToClipboard({ const [isCopied, setIsCopied] = React.useState(false); const copyToClipboard = (value: string) => { - if (typeof window === 'undefined' || !navigator.clipboard.writeText) { - return; - } - if (!value) return; - navigator.clipboard.writeText(value).then(() => { + copyText(value).then(() => { setIsCopied(true); if (onCopy) { diff --git a/packages/go/web/lib/clipboard.test.ts b/packages/go/web/lib/clipboard.test.ts new file mode 100644 index 000000000..b9d8afd2e --- /dev/null +++ b/packages/go/web/lib/clipboard.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard } from './clipboard'; + +function installFallback(execResult: boolean) { + const remove = vi.fn(); + const textArea = { + value: '', + style: {} as Record, + setAttribute: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + remove, + }; + const appendChild = vi.fn(); + const execCommand = vi.fn(() => execResult); + + vi.stubGlobal('document', { + body: { appendChild }, + createElement: vi.fn(() => textArea), + execCommand, + }); + + return { appendChild, execCommand, remove, textArea }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('copyTextToClipboard', () => { + it('uses the Clipboard API when available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const fallback = installFallback(true); + + await copyTextToClipboard('hello'); + + expect(writeText).toHaveBeenCalledWith('hello'); + expect(fallback.appendChild).not.toHaveBeenCalled(); + }); + + it('falls back when navigator.clipboard is unavailable', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(true); + + await copyTextToClipboard('fallback'); + + expect(fallback.textArea.value).toBe('fallback'); + expect(fallback.execCommand).toHaveBeenCalledWith('copy'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('falls back when Clipboard API writing is rejected', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const fallback = installFallback(true); + + await copyTextToClipboard('retry'); + + expect(fallback.execCommand).toHaveBeenCalledWith('copy'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('rejects when the fallback reports failure', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(false); + + await expect(copyTextToClipboard('nope')).rejects.toThrow( + 'Failed to copy text to clipboard', + ); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('cleans up when the fallback throws', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(true); + fallback.execCommand.mockImplementation(() => { + throw new Error('blocked'); + }); + + await expect(copyTextToClipboard('nope')).rejects.toThrow('blocked'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/go/web/lib/clipboard.ts b/packages/go/web/lib/clipboard.ts new file mode 100644 index 000000000..7f9471316 --- /dev/null +++ b/packages/go/web/lib/clipboard.ts @@ -0,0 +1,23 @@ +export async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + + try { + textArea.select(); + if (!document.execCommand('copy')) { + throw new Error('Failed to copy text to clipboard'); + } + } finally { + textArea.remove(); + } +} diff --git a/packages/launcher/src/renderer/components/agent-detail/AgentDetail.tsx b/packages/launcher/src/renderer/components/agent-detail/AgentDetail.tsx index f46cba536..28007f7aa 100644 --- a/packages/launcher/src/renderer/components/agent-detail/AgentDetail.tsx +++ b/packages/launcher/src/renderer/components/agent-detail/AgentDetail.tsx @@ -28,6 +28,7 @@ import { StagedProgress } from "../install-progress/StagedProgress" import { useAgentChannel, channelToDistTag } from "../../hooks/useAgentChannel" import { installErrorMessage, throwIfInstallFailed } from "../../utils/installErrors" import { isLoginOnlyAgent } from "../../lib/agent-auth" +import { copyTextToClipboard } from "../../lib/clipboard" interface AgentDetailProps { entry: CatalogEntry @@ -236,7 +237,7 @@ export default function AgentDetail({ const copyLog = useCallback(async () => { const text = job?.log || "" try { - await navigator.clipboard.writeText(text) + await copyTextToClipboard(text) showToast("Log copied to clipboard", "success") } catch { showToast("Failed to copy log", "error") diff --git a/packages/launcher/src/renderer/components/agent-detail/AgentQuickStart.tsx b/packages/launcher/src/renderer/components/agent-detail/AgentQuickStart.tsx index 6c87f9b39..7ab1f5d8a 100644 --- a/packages/launcher/src/renderer/components/agent-detail/AgentQuickStart.tsx +++ b/packages/launcher/src/renderer/components/agent-detail/AgentQuickStart.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" import type { CatalogEntry } from "../../types" import type { ToastType } from "../../hooks/useToast" +import { copyTextToClipboard } from "../../lib/clipboard" const SECTION = "px-4.5 py-4 bg-(--bg-card) border border-(--border) rounded-(--radius) shadow-sm" const SECTION_H4 = "text-xs font-semibold uppercase tracking-wider text-(--text-secondary) m-0 mb-2.5" @@ -47,7 +48,7 @@ export function AgentQuickStart({ async function copy(cmd: string): Promise { try { - await navigator.clipboard.writeText(cmd) + await copyTextToClipboard(cmd) setCopied(cmd) showToast("Copied to clipboard", "success") setTimeout(() => setCopied((c) => (c === cmd ? null : c)), 1500) diff --git a/packages/launcher/src/renderer/components/chat/Markdown.tsx b/packages/launcher/src/renderer/components/chat/Markdown.tsx index f86d22321..76aeebf87 100644 --- a/packages/launcher/src/renderer/components/chat/Markdown.tsx +++ b/packages/launcher/src/renderer/components/chat/Markdown.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react' import { cn } from '../../lib/utils' +import { copyTextToClipboard } from '../../lib/clipboard' // Lightweight Markdown renderer — supports the subset called out in stage3.md: // headings, paragraphs, bold/italic, inline code, fenced code blocks with copy, @@ -159,7 +160,7 @@ function CodeBlock({ code, lang }: { code: string; lang?: string }): React.JSX.E const [copied, setCopied] = useState(false) const copy = async (): Promise => { try { - await navigator.clipboard.writeText(code) + await copyTextToClipboard(code) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} diff --git a/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx b/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx index 8cbab9e6c..17f66a1bf 100644 --- a/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx +++ b/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx @@ -6,6 +6,7 @@ import { PlatformLogo } from "../connections/PlatformLogo" import { getPlatform } from "../connections/platforms" import { CredentialUsage } from "./CredentialUsage" import type { CredentialSummary } from "../../types" +import { copyTextToClipboard } from "../../lib/clipboard" export function CredentialCard({ cred, @@ -32,7 +33,7 @@ export function CredentialCard({ const copySecret = async (): Promise => { if (!revealed) return try { - await navigator.clipboard.writeText(revealed) + await copyTextToClipboard(revealed) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} diff --git a/packages/launcher/src/renderer/lib/clipboard.ts b/packages/launcher/src/renderer/lib/clipboard.ts new file mode 100644 index 000000000..c562ab5ac --- /dev/null +++ b/packages/launcher/src/renderer/lib/clipboard.ts @@ -0,0 +1,23 @@ +export async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return + } catch {} + } + + const textArea = document.createElement("textarea") + textArea.value = text + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + + try { + textArea.select() + if (!document.execCommand("copy")) { + throw new Error("Failed to copy text to clipboard") + } + } finally { + textArea.remove() + } +} diff --git a/packages/launcher/src/renderer/pages/logs/index.tsx b/packages/launcher/src/renderer/pages/logs/index.tsx index e1cd07e2e..4649bd3a5 100644 --- a/packages/launcher/src/renderer/pages/logs/index.tsx +++ b/packages/launcher/src/renderer/pages/logs/index.tsx @@ -13,6 +13,7 @@ import { } from "../../services/logs/log-parser" import { cn } from "../../lib/utils" import type { ToastType } from "../../hooks/useToast" +import { copyTextToClipboard } from "../../lib/clipboard" const LOGS_INITIAL_LINES = 400 const LOGS_MAX_BUFFER = 2000 @@ -160,8 +161,7 @@ export default function Logs({ showToast }: LogsProps): React.JSX.Element { } const copyLogs = (): void => { - navigator.clipboard - .writeText(logLines.join("\n")) + copyTextToClipboard(logLines.join("\n")) .then(() => showToast("Logs copied to clipboard", "success")) .catch(() => showToast("Failed to copy logs", "error")) } diff --git a/packages/launcher/src/renderer/pages/workspaces/index.tsx b/packages/launcher/src/renderer/pages/workspaces/index.tsx index 8e7d4426e..ddf765939 100644 --- a/packages/launcher/src/renderer/pages/workspaces/index.tsx +++ b/packages/launcher/src/renderer/pages/workspaces/index.tsx @@ -17,6 +17,7 @@ import { useUiStore } from "../../store/ui" import type { Agent, ChatSessionMeta, Workspace } from "../../types" import type { ToastType } from "../../hooks/useToast" import { workspaceWebBaseUrl } from "../../lib/workspace-urls" +import { copyTextToClipboard } from "../../lib/clipboard" interface Props { showToast: (msg: string, type?: ToastType) => void @@ -222,7 +223,7 @@ export default function Workspaces({ showToast }: Props): React.JSX.Element { const url = `${baseUrl}/${slug}` const full = ws.token ? `${url}?token=${encodeURIComponent(ws.token)}` : url try { - await navigator.clipboard.writeText(full) + await copyTextToClipboard(full) setCopiedSlug(slug) setTimeout(() => setCopiedSlug(null), 1500) showToast("URL copied", "success") diff --git a/sdk/studio/src/components/chat/CodeBlock.tsx b/sdk/studio/src/components/chat/CodeBlock.tsx index 1b5cf0f52..57c9273fa 100644 --- a/sdk/studio/src/components/chat/CodeBlock.tsx +++ b/sdk/studio/src/components/chat/CodeBlock.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useThemeStore } from '../../stores/themeStore'; +import { copyTextToClipboard } from '../../utils/clipboard'; interface CodeBlockProps { code: string; @@ -12,10 +13,12 @@ const CodeBlock: React.FC = ({ code, language }) => { const { theme } = useThemeStore(); const [copied, setCopied] = useState(false); - const copyToClipboard = () => { - navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleCopy = async () => { + try { + await copyTextToClipboard(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch {} }; // Map common language aliases to proper ones @@ -53,7 +56,7 @@ const CodeBlock: React.FC = ({ code, language }) => {
{normalizedLanguage}