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
21 changes: 13 additions & 8 deletions packages/go/web/components/agents/connect-agents-empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useState } from 'react';
import { Cpu, Copy, Check, Eye, EyeOff, RefreshCw, ArrowUpRight, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useWorkspace } from '@/lib/workspace-context';
import { copyTextToClipboard } from '@/lib/clipboard';

// Public download page for the desktop Launcher (placeholder — point at the
// real release page before shipping).
Expand Down Expand Up @@ -120,10 +121,12 @@ function StepCard({

function CommandRow({ command }: { command: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
const copy = async () => {
try {
await copyTextToClipboard(command);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {}
};
return (
<div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2.5">
Expand All @@ -150,10 +153,12 @@ function LabeledValue({
}) {
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
const copy = async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {}
};
const shown = secret && !revealed ? '•'.repeat(Math.min(value.length, 24)) : value;
return (
Expand Down
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 @@ -11,6 +11,7 @@ import { MarkdownContent } from './markdown-content';
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 @@ -122,7 +123,7 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C

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 @@ -42,6 +42,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 @@ -176,15 +177,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
98 changes: 98 additions & 0 deletions packages/go/web/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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.textArea.setAttribute).toHaveBeenCalledWith('readonly', '');
expect(fallback.textArea.select).toHaveBeenCalledOnce();
expect(fallback.textArea.setSelectionRange).toHaveBeenCalledWith(0, 8);
expect(fallback.execCommand).toHaveBeenCalledWith('copy');
expect(fallback.remove).toHaveBeenCalledOnce();
});

it('falls back when navigator is unavailable', async () => {
vi.stubGlobal('navigator', undefined);
const fallback = installFallback(true);

await copyTextToClipboard('server-safe');

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();
});
});
25 changes: 25 additions & 0 deletions packages/go/web/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {}
}

const textArea = document.createElement('textarea');
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);

try {
textArea.select();
textArea.setSelectionRange(0, text.length);
if (!document.execCommand('copy')) {
throw new Error('Failed to copy text to clipboard');
}
} finally {
textArea.remove();
}
}
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,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 @@ -161,7 +162,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 @@ -9,6 +9,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"


interface Props {
Expand Down Expand Up @@ -39,7 +40,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
25 changes: 25 additions & 0 deletions packages/launcher/src/renderer/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch {}
}

const textArea = document.createElement("textarea")
textArea.value = text
textArea.setAttribute("readonly", "")
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
document.body.appendChild(textArea)

try {
textArea.select()
textArea.setSelectionRange(0, text.length)
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 @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
import { Button } from "@renderer/components/ui/button"
import type { CatalogEntry } from "@renderer/types"
import type { ToastType } from "@renderer/hooks/useToast"
import { copyTextToClipboard } from "@renderer/lib/clipboard"

interface Props {
entry: CatalogEntry
Expand Down Expand Up @@ -53,7 +54,7 @@ export function DetailQuickStart({ entry, showToast }: Props): React.JSX.Element

async function copy(cmd: string): Promise<void> {
try {
await navigator.clipboard.writeText(cmd)
await copyTextToClipboard(cmd)
setCopied(cmd)
window.setTimeout(() => setCopied((c) => (c === cmd ? null : c)), 1500)
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
import { Button } from "@renderer/components/ui/button"
import { globalUninstallCommand } from "../../../../shared/npm-install-spec"
import type { CatalogEntry } from "@renderer/types"
import { copyTextToClipboard } from "@renderer/lib/clipboard"

function detectPlatform(): "macos" | "linux" | "windows" {
if (typeof navigator === "undefined") return "linux"
Expand Down Expand Up @@ -42,7 +43,7 @@ export function UnmanagedNotice({
async function copy(): Promise<void> {
if (!command) return
try {
await navigator.clipboard.writeText(command)
await copyTextToClipboard(command)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
InstalledAgentRecord,
} from "@renderer/types"
import type { ToastType } from "@renderer/hooks/useToast"
import { copyTextToClipboard } from "@renderer/lib/clipboard"

import type { VersionEntry } from "./detail-versions"

Expand Down Expand Up @@ -241,7 +242,7 @@ export function useAgentDetail({

const copyLog = useCallback(async () => {
try {
await navigator.clipboard.writeText(job?.log || "")
await copyTextToClipboard(job?.log || "")
showToast(t("agents.detail.toast.logCopied"), "success")
} catch {
showToast(t("agents.detail.toast.logCopyFailed"), "error")
Expand Down
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 @@ -21,6 +21,7 @@ import {
} from "@renderer/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@renderer/components/ui/tabs"
import { cn } from "@renderer/lib/utils"
import { copyTextToClipboard } from "@renderer/lib/clipboard"
import { useAgentsStore } from "@renderer/store/agents"
import { formatDateTime } from "@renderer/services/logs/log-metrics"
import type { ParsedLog } from "@renderer/services/logs/log-parser"
Expand Down Expand Up @@ -76,8 +77,7 @@ export default function Logs({ showToast }: LogsProps): React.JSX.Element {
}

const copy = (text: string): void => {
navigator.clipboard
.writeText(text)
copyTextToClipboard(text)
.then(() => showToast(t("logs.toast.copied"), "success"))
.catch(() => showToast(t("logs.toast.copyFailed"), "error"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react"
import { useTranslation } from "react-i18next"
import { ClipboardCopy, FolderOpen, RefreshCw } from "lucide-react"
import { copyTextToClipboard } from "@renderer/lib/clipboard"

import { Badge } from "@renderer/components/ui/badge"
import { Button } from "@renderer/components/ui/button"
Expand Down Expand Up @@ -61,7 +62,7 @@ export function RuntimeSection({
`Logs: ${paths.logs ?? "n/a"}`,
].filter(Boolean)
try {
await navigator.clipboard.writeText(lines.join("\n"))
await copyTextToClipboard(lines.join("\n"))
showToast(t("settings.runtime.diagnosticsCopied"), "success")
} catch {
showToast(t("settings.runtime.diagnosticsCopyFailed"), "error")
Expand Down
Loading
Loading