refactor: unify clipboard copy flow across desktop/web/vscode runtimes (#458)

* fix: prevent copying incomplete diagnostics report

* refactoring: clipboard writes to unified cross-runtime fallback helper
This commit is contained in:
Bohdan Triapitsyn
2026-02-20 14:50:26 +02:00
committed by GitHub
parent 881e8bdf4f
commit 47cecfc356
22 changed files with 232 additions and 209 deletions
+11 -38
View File
@@ -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<ChatMessageProps> = ({
}, [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<ChatMessageProps> = ({
const hasTextContent = messageTextContent.length > 0;
const copyTextToClipboard = React.useCallback(async (text: string): Promise<boolean> => {
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;
@@ -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<HTMLDivElement | nul
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([content], { type: 'text/plain' }),
@@ -275,6 +277,13 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
setShowMenu(false);
setTimeout(() => 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<CodeBlockWrapperProps> = ({ 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);
}
};
@@ -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);
@@ -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<HTMLElement | null>;
@@ -223,10 +224,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ 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();
@@ -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');
@@ -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);
}
}, []);
@@ -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<FileRowProps> = ({
)}
<DropdownMenuItem onClick={(e) => {
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');
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem>
@@ -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);
}
}, []);
@@ -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) => {
@@ -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<SessionSidebarProps> = ({
);
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);
@@ -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<TerminalController, TerminalViewportPr
return;
}
if (navigator.clipboard?.writeText) {
const copied = await navigator.clipboard
.writeText(text)
.then(() => 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 => {
+49 -5
View File
@@ -23,18 +23,29 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
const [version, setVersion] = React.useState<string | null>(null);
const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false);
const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false);
const [diagnosticsReport, setDiagnosticsReport] = React.useState<string | null>(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<AboutDialogProps> = ({
} finally {
setIsCopyingDiagnostics(false);
}
}, [isCopyingDiagnostics]);
}, [diagnosticsReport, isCopyingDiagnostics]);
React.useEffect(() => {
if (!open) return;
@@ -65,6 +76,35 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
}
}, [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<AboutDialogProps> = ({
<div className="flex flex-col items-center gap-2 pt-2">
<button
onClick={handleCopyDiagnostics}
disabled={isCopyingDiagnostics}
disabled={isCopyingDiagnostics || isPreparingDiagnostics || !diagnosticsReport}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{copiedDiagnostics ? 'Diagnostics copied' : 'Copy diagnostics'}
{copiedDiagnostics
? 'Diagnostics copied'
: isPreparingDiagnostics
? 'Preparing diagnostics...'
: 'Copy diagnostics'}
</button>
<p className="typography-micro text-muted-foreground">
Includes OpenChamber state, OpenCode health, directories, and projects.
@@ -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<ErrorBoundaryProps, ErrorBoun
const componentStack = this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : '';
const payload = `${errorText}${stack}${componentStack}`;
try {
await navigator.clipboard.writeText(payload);
const result = await copyTextToClipboard(payload);
if (result.ok) {
this.setState({ copied: true });
window.setTimeout(() => {
this.setState((prev) => (prev.copied ? { copied: false } : null));
}, 1500);
} catch {
// ignore
}
};
@@ -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 (
@@ -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<UpdateDialogProps> = ({
}, [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
}
};
+4 -4
View File
@@ -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)
}
}
+20 -14
View File
@@ -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<FileRowProps> = ({
)}
<DropdownMenuItem onClick={(e) => {
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');
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem>
@@ -2049,8 +2055,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
} else {
toast.error('Copy failed');
}
}}
@@ -2079,8 +2085,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
} else {
toast.error('Copy failed');
}
}}
@@ -2437,8 +2443,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
} else {
toast.error('Copy failed');
}
}}
@@ -2467,8 +2473,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
} else {
toast.error('Copy failed');
}
}}
+7 -7
View File
@@ -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<GitViewProps> = ({ 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) => {
@@ -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
}
}}
@@ -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<AgentGroupDetailProps> = ({
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 () => {
+39
View File
@@ -0,0 +1,39 @@
export type ClipboardCopyResult =
| { ok: true; method: 'clipboard' | 'execCommand' }
| { ok: false; error: string };
export async function copyTextToClipboard(text: string): Promise<ClipboardCopyResult> {
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',
};
}
+7 -5
View File
@@ -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() {