Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/go/web/components/chat/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 10 additions & 5 deletions packages/go/web/components/layout/workspace-switcher-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions packages/go/web/hooks/use-copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import * as React from 'react';
import { copyTextToClipboard as copyText } from '@/lib/clipboard';

export function useCopyToClipboard({
timeout = 2000,
Expand All @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions packages/go/web/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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();
});
});
23 changes: 23 additions & 0 deletions packages/go/web/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export async function copyTextToClipboard(text: string): Promise<void> {
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -47,7 +48,7 @@ export function AgentQuickStart({

async function copy(cmd: string): Promise<void> {
try {
await navigator.clipboard.writeText(cmd)
await copyTextToClipboard(cmd)
setCopied(cmd)
showToast("Copied to clipboard", "success")
setTimeout(() => setCopied((c) => (c === cmd ? null : c)), 1500)
Expand Down
3 changes: 2 additions & 1 deletion packages/launcher/src/renderer/components/chat/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -159,7 +160,7 @@ function CodeBlock({ code, lang }: { code: string; lang?: string }): React.JSX.E
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(code)
await copyTextToClipboard(code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +33,7 @@ export function CredentialCard({
const copySecret = async (): Promise<void> => {
if (!revealed) return
try {
await navigator.clipboard.writeText(revealed)
await copyTextToClipboard(revealed)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
Expand Down
23 changes: 23 additions & 0 deletions packages/launcher/src/renderer/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export async function copyTextToClipboard(text: string): Promise<void> {
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()
}
}
4 changes: 2 additions & 2 deletions packages/launcher/src/renderer/pages/logs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
Expand Down
3 changes: 2 additions & 1 deletion packages/launcher/src/renderer/pages/workspaces/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 9 additions & 6 deletions sdk/studio/src/components/chat/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,10 +13,12 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ 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
Expand Down Expand Up @@ -53,7 +56,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
<div className="code-header flex justify-between items-center px-3 py-2 bg-gray-200 dark:bg-gray-700 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<span className="font-mono">{normalizedLanguage}</span>
<button
onClick={copyToClipboard}
onClick={handleCopy}
className="copy-button bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-2 py-1 rounded text-xs transition-colors"
>
{copied ? 'Copied!' : 'Copy code'}
Expand All @@ -79,4 +82,4 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
);
};

export default CodeBlock;
export default CodeBlock;
7 changes: 2 additions & 5 deletions sdk/studio/src/hooks/use-copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import * as React from 'react';
import { copyTextToClipboard as copyText } from '@/utils/clipboard';

export function useCopyToClipboard({
timeout = 2000,
Expand All @@ -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) {
Expand Down
Loading
Loading