diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 32db1ac8..42b8a851 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -25,6 +25,7 @@ import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import type { TurnGroupingContext } from './hooks/useTurnGrouping'; +import { copyTextToClipboard } from '@/lib/clipboard'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); @@ -578,9 +579,13 @@ const ChatMessage: React.FC = ({ }, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]); const handleCopyCode = React.useCallback((code: string) => { - navigator.clipboard.writeText(code); - setCopiedCode(code); - setTimeout(() => setCopiedCode(null), 2000); + void copyTextToClipboard(code).then((result) => { + if (!result.ok) { + return; + } + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + }); }, []); const userMessageIdForTurn = turnGroupingContext?.turnId; @@ -704,46 +709,14 @@ const ChatMessage: React.FC = ({ const hasTextContent = messageTextContent.length > 0; - const copyTextToClipboard = React.useCallback(async (text: string): Promise => { - if (!text) { - return false; - } - - if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (error) { - void error; - } - } - - if (typeof document === 'undefined') { - return false; - } - - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.top = '-1000px'; - textarea.style.left = '-1000px'; - document.body.appendChild(textarea); - textarea.select(); - textarea.setSelectionRange(0, textarea.value.length); - const succeeded = document.execCommand('copy'); - document.body.removeChild(textarea); - return succeeded; - }, []); - const handleCopyMessage = React.useCallback(async () => { - const didCopy = await copyTextToClipboard(messageTextContent); - if (!didCopy) { + const result = await copyTextToClipboard(messageTextContent); + if (!result.ok) { return; } setCopiedMessage(true); setTimeout(() => setCopiedMessage(false), 2000); - }, [copyTextToClipboard, messageTextContent]); + }, [messageTextContent]); const handleRevert = React.useCallback(() => { if (!sessionId || !message.info.id) return; diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 94f49910..eaae1f95 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -8,6 +8,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; @@ -262,9 +263,10 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject setCopied(false), 2000); } catch (err) { + const fallbackResult = await copyTextToClipboard(content); + if (fallbackResult.ok) { + setCopied(true); + setShowMenu(false); + setTimeout(() => setCopied(false), 2000); + return; + } console.error('Failed to copy table:', err); } }; @@ -481,12 +490,12 @@ const CodeBlockWrapper: React.FC = ({ children, className const handleCopy = async () => { const code = getCodeContent(); if (!code) return; - try { - await navigator.clipboard.writeText(code); + const result = await copyTextToClipboard(code); + if (result.ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); + } else { + console.error('Failed to copy:', result.error); } }; @@ -539,12 +548,12 @@ const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => { const handleCopy = async () => { if (!source) return; - try { - await navigator.clipboard.writeText(source); + const result = await copyTextToClipboard(source); + if (result.ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy diagram:', err); + } else { + console.error('Failed to copy diagram:', result.error); } }; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index f8c62fbb..83f0168d 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -27,6 +27,7 @@ import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executi import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useConfigStore } from '@/stores/useConfigStore'; import { TextSelectionMenu } from './TextSelectionMenu'; +import { copyTextToClipboard } from '@/lib/clipboard'; type SubtaskPartLike = Part & { type: 'subtask'; @@ -165,32 +166,8 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) const copyOutputToClipboard = React.useCallback(async () => { if (!hasOutput) return; - let succeeded = false; - - if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) { - try { - await navigator.clipboard.writeText(output); - succeeded = true; - } catch { - succeeded = false; - } - } - - if (!succeeded && typeof document !== 'undefined') { - const textarea = document.createElement('textarea'); - textarea.value = output; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.top = '-1000px'; - textarea.style.left = '-1000px'; - document.body.appendChild(textarea); - textarea.select(); - textarea.setSelectionRange(0, textarea.value.length); - succeeded = document.execCommand('copy'); - document.body.removeChild(textarea); - } - - if (!succeeded) return; + const result = await copyTextToClipboard(output); + if (!result.ok) return; clearCopiedResetTimeout(); setCopiedOutput(true); diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 737e4c87..eb4c777c 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -4,6 +4,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; interface TextSelectionMenuProps { containerRef: React.RefObject; @@ -223,10 +224,9 @@ export const TextSelectionMenu: React.FC = ({ containerR const handleCopy = React.useCallback(async () => { if (!selectedText) return; - try { - await navigator.clipboard.writeText(selectedText); - } catch (err) { - console.error('Failed to copy:', err); + const result = await copyTextToClipboard(selectedText); + if (!result.ok) { + console.error('Failed to copy:', result.error); } hideMenu(); diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx index e8f52660..c527afbe 100644 --- a/packages/ui/src/components/desktop/OpenInAppButton.tsx +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dropdown-menu'; import { toast } from '@/components/ui'; import { updateDesktopSettings } from '@/lib/persistence'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react'; @@ -302,29 +303,9 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) }; const handleCopyPath = async () => { - if (typeof navigator === 'undefined') return; const text = directory; - let copied = false; - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text); - copied = true; - } catch { - // fall through - } - } - if (!copied) { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'absolute'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - copied = document.execCommand('copy'); - document.body.removeChild(textarea); - } - if (!copied) { + const result = await copyTextToClipboard(text); + if (!result.ok) { return; } toast.success('Path copied to clipboard'); diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 963c519d..b8550424 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -8,6 +8,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; +import { copyTextToClipboard } from '@/lib/clipboard'; type SessionMessage = { info: Message; parts: Part[] }; @@ -303,8 +304,8 @@ export const ContextPanelContent: React.FC = () => { }, []); const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => { - try { - await navigator.clipboard.writeText(value); + const result = await copyTextToClipboard(value); + if (result.ok) { setCopiedRawMessageId(messageId); if (copyResetTimeoutRef.current !== null) { window.clearTimeout(copyResetTimeoutRef.current); @@ -313,7 +314,7 @@ export const ContextPanelContent: React.FC = () => { setCopiedRawMessageId((prev) => (prev === messageId ? null : prev)); copyResetTimeoutRef.current = null; }, 2000); - } catch { + } else { setCopiedRawMessageId(null); } }, []); diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index bdb1a006..9df244bb 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -45,6 +45,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useGitStatus } from '@/stores/useGitStore'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; @@ -286,8 +287,13 @@ const FileRow: React.FC = ({ )} { e.stopPropagation(); - void navigator.clipboard.writeText(node.path); - toast.success('Path copied'); + void copyTextToClipboard(node.path).then((result) => { + if (result.ok) { + toast.success('Path copied'); + return; + } + toast.error('Copy failed'); + }); }}> Copy Path diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx index 3c771b6d..59b31f9b 100644 --- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx +++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx @@ -4,6 +4,7 @@ import { isDesktopShell, isTauriShell } from '@/lib/desktop'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { updateDesktopSettings } from '@/lib/persistence'; +import { copyTextToClipboard } from '@/lib/clipboard'; const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; const POLL_INTERVAL_MS = 3000; @@ -146,12 +147,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { }, [opencodeBinary]); const handleCopy = React.useCallback(async () => { - try { - await navigator.clipboard.writeText(INSTALL_COMMAND); + const result = await copyTextToClipboard(INSTALL_COMMAND); + if (result.ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); + } else { + console.error('Failed to copy:', result.error); } }, []); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index c5354a6a..0bae9789 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -14,6 +14,7 @@ import { toast } from '@/components/ui'; import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; import type { ModelMetadata } from '@/types'; const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { @@ -448,23 +449,23 @@ export const ProvidersPage: React.FC = () => { }; const handleCopyOAuthLink = async (url: string) => { - try { - await navigator.clipboard.writeText(url); + const result = await copyTextToClipboard(url); + if (result.ok) { toast.success('OAuth link copied'); - } catch (error) { - console.error('Failed to copy OAuth link:', error); - toast.error('Failed to copy OAuth link'); + return; } + console.error('Failed to copy OAuth link:', result.error); + toast.error('Failed to copy OAuth link'); }; const handleCopyOAuthCode = async (code: string) => { - try { - await navigator.clipboard.writeText(code); + const result = await copyTextToClipboard(code); + if (result.ok) { toast.success('Device code copied'); - } catch (error) { - console.error('Failed to copy device code:', error); - toast.error('Failed to copy device code'); + return; } + console.error('Failed to copy device code:', result.error); + toast.error('Failed to copy device code'); }; const handleDisconnectProvider = async (providerId: string) => { diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index e9f5e365..a4ee7662 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; import { DndContext, @@ -1039,9 +1040,12 @@ export const SessionSidebar: React.FC = ({ ); const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { - navigator.clipboard - .writeText(url) - .then(() => { + void copyTextToClipboard(url) + .then((result) => { + if (!result.ok) { + toast.error('Failed to copy URL'); + return; + } setCopiedSessionId(sessionId); if (copyTimeout.current) { clearTimeout(copyTimeout.current); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index b7e4ea7b..663b61e0 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -6,6 +6,7 @@ import { isMobileDeviceViaCSS } from '@/lib/device'; import type { TerminalTheme } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import type { TerminalChunk } from '@/stores/useTerminalStore'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; @@ -391,29 +392,7 @@ const TerminalViewport = React.forwardRef true) - .catch(() => false); - if (copied) { - return; - } - } - - try { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } catch { - return; - } + await copyTextToClipboard(text); }, [getDomSelectionTextInViewport, getTerminalSelectionText]); const hasCopyableSelectionInViewport = React.useCallback((): boolean => { diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index bfdd78bd..8044d792 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -23,18 +23,29 @@ export const AboutDialog: React.FC = ({ const [version, setVersion] = React.useState(null); const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false); const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false); + const [diagnosticsReport, setDiagnosticsReport] = React.useState(null); + const [isPreparingDiagnostics, setIsPreparingDiagnostics] = React.useState(false); const handleCopyDiagnostics = React.useCallback(async () => { if (isCopyingDiagnostics) return; setIsCopyingDiagnostics(true); setCopiedDiagnostics(false); try { - const result = await debugUtils.copyDiagnosticsReport(); + if (!diagnosticsReport) { + toast.error('Copy failed', { + description: 'Diagnostics not ready yet. Wait a second and retry.', + }); + return; + } + + const result = await debugUtils.copyTextToClipboard(diagnosticsReport); if (result.ok) { setCopiedDiagnostics(true); toast.success('Diagnostics copied'); } else { - toast.error('Copy failed'); + toast.error('Copy failed', { + description: result.error, + }); } } catch (error) { toast.error('Copy failed'); @@ -42,7 +53,7 @@ export const AboutDialog: React.FC = ({ } finally { setIsCopyingDiagnostics(false); } - }, [isCopyingDiagnostics]); + }, [diagnosticsReport, isCopyingDiagnostics]); React.useEffect(() => { if (!open) return; @@ -65,6 +76,35 @@ export const AboutDialog: React.FC = ({ } }, [open]); + React.useEffect(() => { + if (!open) { + setDiagnosticsReport(null); + setIsPreparingDiagnostics(false); + return; + } + + let cancelled = false; + setIsPreparingDiagnostics(true); + void debugUtils.buildDiagnosticsReport() + .then((report) => { + if (cancelled) return; + setDiagnosticsReport(report); + }) + .catch((error) => { + if (cancelled) return; + console.error('Failed to prepare diagnostics:', error); + setDiagnosticsReport(null); + }) + .finally(() => { + if (cancelled) return; + setIsPreparingDiagnostics(false); + }); + + return () => { + cancelled = true; + }; + }, [open]); + const displayVersion = version; return ( @@ -98,14 +138,18 @@ export const AboutDialog: React.FC = ({

Includes OpenChamber state, OpenCode health, directories, and projects. diff --git a/packages/ui/src/components/ui/ErrorBoundary.tsx b/packages/ui/src/components/ui/ErrorBoundary.tsx index 4101991b..c6b4c426 100644 --- a/packages/ui/src/components/ui/ErrorBoundary.tsx +++ b/packages/ui/src/components/ui/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { RiErrorWarningLine, RiRestartLine } from '@remixicon/react'; import { Button } from './button'; import { Card, CardContent, CardHeader, CardTitle } from './card'; +import { copyTextToClipboard } from '@/lib/clipboard'; interface ErrorBoundaryState { hasError: boolean; @@ -41,14 +42,12 @@ export class ErrorBoundary extends React.Component { this.setState((prev) => (prev.copied ? { copied: false } : null)); }, 1500); - } catch { - // ignore } }; diff --git a/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx index a3577200..9929fb78 100644 --- a/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx +++ b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dialog'; import { toast } from '@/components/ui'; import { useUIStore } from '@/stores/useUIStore'; +import { copyTextToClipboard } from '@/lib/clipboard'; export const OpenCodeStatusDialog: React.FC = () => { const { @@ -16,19 +17,17 @@ export const OpenCodeStatusDialog: React.FC = () => { openCodeStatusText, } = useUIStore(); - const handleCopy = React.useCallback(() => { + const handleCopy = React.useCallback(async () => { if (!openCodeStatusText) { return; } - void navigator.clipboard - .writeText(openCodeStatusText) - .then(() => { - toast.success('Copied', { description: 'OpenCode status copied to clipboard.' }); - }) - .catch(() => { - toast.error('Copy failed'); - }); + const result = await copyTextToClipboard(openCodeStatusText); + if (result.ok) { + toast.success('Copied', { description: 'OpenCode status copied to clipboard.' }); + return; + } + toast.error('Copy failed'); }, [openCodeStatusText]); return ( diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index 92fb2f06..02743f2b 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -10,6 +10,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; +import { copyTextToClipboard } from '@/lib/clipboard'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; @@ -172,11 +173,11 @@ export const UpdateDialog: React.FC = ({ }, [open]); const handleCopyCommand = async () => { - try { - await navigator.clipboard.writeText(updateCommand); + const result = await copyTextToClipboard(updateCommand); + if (result.ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch { + } else { // Clipboard access denied } }; diff --git a/packages/ui/src/components/ui/toast.ts b/packages/ui/src/components/ui/toast.ts index 002fa69e..f2694c16 100644 --- a/packages/ui/src/components/ui/toast.ts +++ b/packages/ui/src/components/ui/toast.ts @@ -3,12 +3,12 @@ import { isValidElement } from "react" import { toast as sonnerToast } from "sonner" import type { ExternalToast } from "sonner" +import { copyTextToClipboard } from '@/lib/clipboard' const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text) - } catch (err) { - console.error('Failed to copy to clipboard:', err) + const result = await copyTextToClipboard(text) + if (!result.ok) { + console.error('Failed to copy to clipboard:', result.error) } } diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 80146cfe..af93130b 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -27,6 +27,7 @@ import { RiFileCopyLine, } from '@remixicon/react'; import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { DropdownMenu, @@ -447,8 +448,13 @@ const FileRow: React.FC = ({ )} { e.stopPropagation(); - void navigator.clipboard.writeText(node.path); - toast.success('Path copied'); + void copyTextToClipboard(node.path).then((result) => { + if (result.ok) { + toast.success('Path copied'); + return; + } + toast.error('Copy failed'); + }); }}> Copy Path @@ -2049,8 +2055,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(fileContent); + const result = await copyTextToClipboard(fileContent); + if (result.ok) { setCopiedContent(true); if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); @@ -2058,7 +2064,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { copiedContentTimeoutRef.current = window.setTimeout(() => { setCopiedContent(false); }, 1200); - } catch { + } else { toast.error('Copy failed'); } }} @@ -2079,8 +2085,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(displaySelectedPath); + const result = await copyTextToClipboard(displaySelectedPath); + if (result.ok) { setCopiedPath(true); if (copiedPathTimeoutRef.current !== null) { window.clearTimeout(copiedPathTimeoutRef.current); @@ -2088,7 +2094,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { copiedPathTimeoutRef.current = window.setTimeout(() => { setCopiedPath(false); }, 1200); - } catch { + } else { toast.error('Copy failed'); } }} @@ -2437,8 +2443,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(fileContent); + const result = await copyTextToClipboard(fileContent); + if (result.ok) { setCopiedContent(true); if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); @@ -2446,7 +2452,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { copiedContentTimeoutRef.current = window.setTimeout(() => { setCopiedContent(false); }, 1200); - } catch { + } else { toast.error('Copy failed'); } }} @@ -2467,8 +2473,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(displaySelectedPath); + const result = await copyTextToClipboard(displaySelectedPath); + if (result.ok) { setCopiedPath(true); if (copiedPathTimeoutRef.current !== null) { window.clearTimeout(copiedPathTimeoutRef.current); @@ -2476,7 +2482,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { copiedPathTimeoutRef.current = window.setTimeout(() => { setCopiedPath(false); }, 1200); - } catch { + } else { toast.error('Copy failed'); } }} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index ed2aedc9..104b4d8c 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -6,6 +6,7 @@ import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { useGitStore, useGitStatus, @@ -473,14 +474,13 @@ export const GitView: React.FC = ({ mode = 'full' }) => { const [stashDialogBranch, setStashDialogBranch] = React.useState(''); const handleCopyCommitHash = React.useCallback((hash: string) => { - navigator.clipboard - .writeText(hash) - .then(() => { + void copyTextToClipboard(hash).then((result) => { + if (result.ok) { toast.success('Commit hash copied'); - }) - .catch(() => { - toast.error('Failed to copy'); - }); + return; + } + toast.error('Failed to copy'); + }); }, []); const handleToggleCommit = React.useCallback((hash: string) => { diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 94156afa..2dfd5f86 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -21,6 +21,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; const normalize = (value: string): string => { if (!value) return ''; @@ -428,8 +429,8 @@ export const PlanView: React.FC = () => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(content); + const result = await copyTextToClipboard(content); + if (result.ok) { setCopiedContent(true); if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); @@ -437,7 +438,7 @@ export const PlanView: React.FC = () => { copiedContentTimeoutRef.current = window.setTimeout(() => { setCopiedContent(false); }, 1200); - } catch { + } else { // ignored } }} @@ -455,8 +456,8 @@ export const PlanView: React.FC = () => { variant="ghost" size="sm" onClick={async () => { - try { - await navigator.clipboard.writeText(displayPath ?? resolvedPath); + const result = await copyTextToClipboard(displayPath ?? resolvedPath); + if (result.ok) { setCopiedPath(true); if (copiedTimeoutRef.current !== null) { window.clearTimeout(copiedTimeoutRef.current); @@ -464,7 +465,7 @@ export const PlanView: React.FC = () => { copiedTimeoutRef.current = window.setTimeout(() => { setCopiedPath(false); }, 1200); - } catch { + } else { // ignored } }} diff --git a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx index 25ba6126..de54ead7 100644 --- a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx @@ -8,6 +8,7 @@ import { } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; @@ -88,14 +89,13 @@ export const AgentGroupDetail: React.FC = ({ toast.error('No worktree path available'); return; } - navigator.clipboard - .writeText(selectedSession.path) - .then(() => { + void copyTextToClipboard(selectedSession.path).then((result) => { + if (result.ok) { toast.success('Worktree path copied'); - }) - .catch(() => { - toast.error('Failed to copy path'); - }); + return; + } + toast.error('Failed to copy path'); + }); }, [selectedSession?.path]); const handleRemoveSelectedWorktree = React.useCallback(async () => { diff --git a/packages/ui/src/lib/clipboard.ts b/packages/ui/src/lib/clipboard.ts new file mode 100644 index 00000000..1002dea6 --- /dev/null +++ b/packages/ui/src/lib/clipboard.ts @@ -0,0 +1,39 @@ +export type ClipboardCopyResult = + | { ok: true; method: 'clipboard' | 'execCommand' } + | { ok: false; error: string }; + +export async function copyTextToClipboard(text: string): Promise { + let clipboardError: string | null = null; + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return { ok: true, method: 'clipboard' }; + } catch (error) { + clipboardError = error instanceof Error ? error.message : String(error); + } + } + + if (typeof document !== 'undefined' && document.body) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.left = '-1000px'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (copied) { + return { ok: true, method: 'execCommand' }; + } + } + + return { + ok: false, + error: clipboardError ?? 'Clipboard access denied in current context', + }; +} diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 5e0620a0..9c82de7a 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -5,6 +5,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; +import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; export interface DebugMessageInfo { messageId: string; @@ -373,13 +374,14 @@ export const debugUtils = { return JSON.stringify(report, null, 2); }, + async copyTextToClipboard(text: string) { + return copyPlainTextToClipboard(text); + }, + async copyDiagnosticsReport() { const report = await this.buildDiagnosticsReport(); - if (typeof navigator !== 'undefined' && navigator.clipboard) { - await navigator.clipboard.writeText(report); - return { ok: true, report } as const; - } - return { ok: false, report } as const; + const result = await this.copyTextToClipboard(report); + return { ...result, report } as const; }, checkLastMessage() {