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 { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { FadeInOnReveal } from './message/FadeInOnReveal'; import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { TurnGroupingContext } from './hooks/useTurnGrouping'; import type { TurnGroupingContext } from './hooks/useTurnGrouping';
import { copyTextToClipboard } from '@/lib/clipboard';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
@@ -578,9 +579,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]); }, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
const handleCopyCode = React.useCallback((code: string) => { const handleCopyCode = React.useCallback((code: string) => {
navigator.clipboard.writeText(code); void copyTextToClipboard(code).then((result) => {
setCopiedCode(code); if (!result.ok) {
setTimeout(() => setCopiedCode(null), 2000); return;
}
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
});
}, []); }, []);
const userMessageIdForTurn = turnGroupingContext?.turnId; const userMessageIdForTurn = turnGroupingContext?.turnId;
@@ -704,46 +709,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const hasTextContent = messageTextContent.length > 0; 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 handleCopyMessage = React.useCallback(async () => {
const didCopy = await copyTextToClipboard(messageTextContent); const result = await copyTextToClipboard(messageTextContent);
if (!didCopy) { if (!result.ok) {
return; return;
} }
setCopiedMessage(true); setCopiedMessage(true);
setTimeout(() => setCopiedMessage(false), 2000); setTimeout(() => setCopiedMessage(false), 2000);
}, [copyTextToClipboard, messageTextContent]); }, [messageTextContent]);
const handleRevert = React.useCallback(() => { const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return; if (!sessionId || !message.info.id) return;
@@ -8,6 +8,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -262,9 +263,10 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
const tableEl = tableRef.current?.querySelector('table'); const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return; if (!tableEl) return;
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
try { try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new ClipboardItem({
'text/plain': new Blob([content], { type: 'text/plain' }), 'text/plain': new Blob([content], { type: 'text/plain' }),
@@ -275,6 +277,13 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
setShowMenu(false); setShowMenu(false);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (err) { } 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); console.error('Failed to copy table:', err);
} }
}; };
@@ -481,12 +490,12 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
const handleCopy = async () => { const handleCopy = async () => {
const code = getCodeContent(); const code = getCodeContent();
if (!code) return; if (!code) return;
try { const result = await copyTextToClipboard(code);
await navigator.clipboard.writeText(code); if (result.ok) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (err) { } else {
console.error('Failed to copy:', err); console.error('Failed to copy:', result.error);
} }
}; };
@@ -539,12 +548,12 @@ const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
const handleCopy = async () => { const handleCopy = async () => {
if (!source) return; if (!source) return;
try { const result = await copyTextToClipboard(source);
await navigator.clipboard.writeText(source); if (result.ok) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (err) { } else {
console.error('Failed to copy diagram:', err); 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 { useMessageTTS } from '@/hooks/useMessageTTS';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { TextSelectionMenu } from './TextSelectionMenu'; import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
type SubtaskPartLike = Part & { type SubtaskPartLike = Part & {
type: 'subtask'; type: 'subtask';
@@ -165,32 +166,8 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
const copyOutputToClipboard = React.useCallback(async () => { const copyOutputToClipboard = React.useCallback(async () => {
if (!hasOutput) return; if (!hasOutput) return;
let succeeded = false; const result = await copyTextToClipboard(output);
if (!result.ok) return;
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;
clearCopiedResetTimeout(); clearCopiedResetTimeout();
setCopiedOutput(true); setCopiedOutput(true);
@@ -4,6 +4,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
interface TextSelectionMenuProps { interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
@@ -223,10 +224,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleCopy = React.useCallback(async () => { const handleCopy = React.useCallback(async () => {
if (!selectedText) return; if (!selectedText) return;
try { const result = await copyTextToClipboard(selectedText);
await navigator.clipboard.writeText(selectedText); if (!result.ok) {
} catch (err) { console.error('Failed to copy:', result.error);
console.error('Failed to copy:', err);
} }
hideMenu(); hideMenu();
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react'; import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
@@ -302,29 +303,9 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
}; };
const handleCopyPath = async () => { const handleCopyPath = async () => {
if (typeof navigator === 'undefined') return;
const text = directory; const text = directory;
let copied = false; const result = await copyTextToClipboard(text);
if (navigator.clipboard?.writeText) { if (!result.ok) {
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) {
return; return;
} }
toast.success('Path copied to clipboard'); toast.success('Path copied to clipboard');
@@ -8,6 +8,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { copyTextToClipboard } from '@/lib/clipboard';
type SessionMessage = { info: Message; parts: Part[] }; type SessionMessage = { info: Message; parts: Part[] };
@@ -303,8 +304,8 @@ export const ContextPanelContent: React.FC = () => {
}, []); }, []);
const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => { const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => {
try { const result = await copyTextToClipboard(value);
await navigator.clipboard.writeText(value); if (result.ok) {
setCopiedRawMessageId(messageId); setCopiedRawMessageId(messageId);
if (copyResetTimeoutRef.current !== null) { if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current); window.clearTimeout(copyResetTimeoutRef.current);
@@ -313,7 +314,7 @@ export const ContextPanelContent: React.FC = () => {
setCopiedRawMessageId((prev) => (prev === messageId ? null : prev)); setCopiedRawMessageId((prev) => (prev === messageId ? null : prev));
copyResetTimeoutRef.current = null; copyResetTimeoutRef.current = null;
}, 2000); }, 2000);
} catch { } else {
setCopiedRawMessageId(null); setCopiedRawMessageId(null);
} }
}, []); }, []);
@@ -45,6 +45,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useGitStatus } from '@/stores/useGitStore'; import { useGitStatus } from '@/stores/useGitStore';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
@@ -286,8 +287,13 @@ const FileRow: React.FC<FileRowProps> = ({
)} )}
<DropdownMenuItem onClick={(e) => { <DropdownMenuItem onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void navigator.clipboard.writeText(node.path); void copyTextToClipboard(node.path).then((result) => {
toast.success('Path copied'); if (result.ok) {
toast.success('Path copied');
return;
}
toast.error('Copy failed');
});
}}> }}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path <RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem> </DropdownMenuItem>
@@ -4,6 +4,7 @@ import { isDesktopShell, isTauriShell } from '@/lib/desktop';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
@@ -146,12 +147,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
}, [opencodeBinary]); }, [opencodeBinary]);
const handleCopy = React.useCallback(async () => { const handleCopy = React.useCallback(async () => {
try { const result = await copyTextToClipboard(INSTALL_COMMAND);
await navigator.clipboard.writeText(INSTALL_COMMAND); if (result.ok) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (err) { } else {
console.error('Failed to copy:', err); 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 { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ModelMetadata } from '@/types'; import type { ModelMetadata } from '@/types';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
@@ -448,23 +449,23 @@ export const ProvidersPage: React.FC = () => {
}; };
const handleCopyOAuthLink = async (url: string) => { const handleCopyOAuthLink = async (url: string) => {
try { const result = await copyTextToClipboard(url);
await navigator.clipboard.writeText(url); if (result.ok) {
toast.success('OAuth link copied'); toast.success('OAuth link copied');
} catch (error) { return;
console.error('Failed to copy OAuth link:', error);
toast.error('Failed to copy OAuth link');
} }
console.error('Failed to copy OAuth link:', result.error);
toast.error('Failed to copy OAuth link');
}; };
const handleCopyOAuthCode = async (code: string) => { const handleCopyOAuthCode = async (code: string) => {
try { const result = await copyTextToClipboard(code);
await navigator.clipboard.writeText(code); if (result.ok) {
toast.success('Device code copied'); toast.success('Device code copied');
} catch (error) { return;
console.error('Failed to copy device code:', error);
toast.error('Failed to copy device code');
} }
console.error('Failed to copy device code:', result.error);
toast.error('Failed to copy device code');
}; };
const handleDisconnectProvider = async (providerId: string) => { const handleDisconnectProvider = async (providerId: string) => {
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { import {
DndContext, DndContext,
@@ -1039,9 +1040,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
); );
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
navigator.clipboard void copyTextToClipboard(url)
.writeText(url) .then((result) => {
.then(() => { if (!result.ok) {
toast.error('Failed to copy URL');
return;
}
setCopiedSessionId(sessionId); setCopiedSessionId(sessionId);
if (copyTimeout.current) { if (copyTimeout.current) {
clearTimeout(copyTimeout.current); clearTimeout(copyTimeout.current);
@@ -6,6 +6,7 @@ import { isMobileDeviceViaCSS } from '@/lib/device';
import type { TerminalTheme } from '@/lib/terminalTheme'; import type { TerminalTheme } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore'; import type { TerminalChunk } from '@/stores/useTerminalStore';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
@@ -391,29 +392,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
if (navigator.clipboard?.writeText) { await copyTextToClipboard(text);
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;
}
}, [getDomSelectionTextInViewport, getTerminalSelectionText]); }, [getDomSelectionTextInViewport, getTerminalSelectionText]);
const hasCopyableSelectionInViewport = React.useCallback((): boolean => { 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 [version, setVersion] = React.useState<string | null>(null);
const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false); const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false);
const [copiedDiagnostics, setCopiedDiagnostics] = 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 () => { const handleCopyDiagnostics = React.useCallback(async () => {
if (isCopyingDiagnostics) return; if (isCopyingDiagnostics) return;
setIsCopyingDiagnostics(true); setIsCopyingDiagnostics(true);
setCopiedDiagnostics(false); setCopiedDiagnostics(false);
try { 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) { if (result.ok) {
setCopiedDiagnostics(true); setCopiedDiagnostics(true);
toast.success('Diagnostics copied'); toast.success('Diagnostics copied');
} else { } else {
toast.error('Copy failed'); toast.error('Copy failed', {
description: result.error,
});
} }
} catch (error) { } catch (error) {
toast.error('Copy failed'); toast.error('Copy failed');
@@ -42,7 +53,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
} finally { } finally {
setIsCopyingDiagnostics(false); setIsCopyingDiagnostics(false);
} }
}, [isCopyingDiagnostics]); }, [diagnosticsReport, isCopyingDiagnostics]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@@ -65,6 +76,35 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
} }
}, [open]); }, [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; const displayVersion = version;
return ( return (
@@ -98,14 +138,18 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
<div className="flex flex-col items-center gap-2 pt-2"> <div className="flex flex-col items-center gap-2 pt-2">
<button <button
onClick={handleCopyDiagnostics} onClick={handleCopyDiagnostics}
disabled={isCopyingDiagnostics} disabled={isCopyingDiagnostics || isPreparingDiagnostics || !diagnosticsReport}
className={cn( className={cn(
'typography-meta text-muted-foreground hover:text-foreground', 'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline', 'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed' 'disabled:opacity-50 disabled:cursor-not-allowed'
)} )}
> >
{copiedDiagnostics ? 'Diagnostics copied' : 'Copy diagnostics'} {copiedDiagnostics
? 'Diagnostics copied'
: isPreparingDiagnostics
? 'Preparing diagnostics...'
: 'Copy diagnostics'}
</button> </button>
<p className="typography-micro text-muted-foreground"> <p className="typography-micro text-muted-foreground">
Includes OpenChamber state, OpenCode health, directories, and projects. Includes OpenChamber state, OpenCode health, directories, and projects.
@@ -2,6 +2,7 @@ import React from 'react';
import { RiErrorWarningLine, RiRestartLine } from '@remixicon/react'; import { RiErrorWarningLine, RiRestartLine } from '@remixicon/react';
import { Button } from './button'; import { Button } from './button';
import { Card, CardContent, CardHeader, CardTitle } from './card'; import { Card, CardContent, CardHeader, CardTitle } from './card';
import { copyTextToClipboard } from '@/lib/clipboard';
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean; 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 componentStack = this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : '';
const payload = `${errorText}${stack}${componentStack}`; const payload = `${errorText}${stack}${componentStack}`;
try { const result = await copyTextToClipboard(payload);
await navigator.clipboard.writeText(payload); if (result.ok) {
this.setState({ copied: true }); this.setState({ copied: true });
window.setTimeout(() => { window.setTimeout(() => {
this.setState((prev) => (prev.copied ? { copied: false } : null)); this.setState((prev) => (prev.copied ? { copied: false } : null));
}, 1500); }, 1500);
} catch {
// ignore
} }
}; };
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { copyTextToClipboard } from '@/lib/clipboard';
export const OpenCodeStatusDialog: React.FC = () => { export const OpenCodeStatusDialog: React.FC = () => {
const { const {
@@ -16,19 +17,17 @@ export const OpenCodeStatusDialog: React.FC = () => {
openCodeStatusText, openCodeStatusText,
} = useUIStore(); } = useUIStore();
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(async () => {
if (!openCodeStatusText) { if (!openCodeStatusText) {
return; return;
} }
void navigator.clipboard const result = await copyTextToClipboard(openCodeStatusText);
.writeText(openCodeStatusText) if (result.ok) {
.then(() => { toast.success('Copied', { description: 'OpenCode status copied to clipboard.' });
toast.success('Copied', { description: 'OpenCode status copied to clipboard.' }); return;
}) }
.catch(() => { toast.error('Copy failed');
toast.error('Copy failed');
});
}, [openCodeStatusText]); }, [openCodeStatusText]);
return ( return (
@@ -10,6 +10,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react'; import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { copyTextToClipboard } from '@/lib/clipboard';
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
@@ -172,11 +173,11 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
}, [open]); }, [open]);
const handleCopyCommand = async () => { const handleCopyCommand = async () => {
try { const result = await copyTextToClipboard(updateCommand);
await navigator.clipboard.writeText(updateCommand); if (result.ok) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { } else {
// Clipboard access denied // Clipboard access denied
} }
}; };
+4 -4
View File
@@ -3,12 +3,12 @@
import { isValidElement } from "react" import { isValidElement } from "react"
import { toast as sonnerToast } from "sonner" import { toast as sonnerToast } from "sonner"
import type { ExternalToast } from "sonner" import type { ExternalToast } from "sonner"
import { copyTextToClipboard } from '@/lib/clipboard'
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { const result = await copyTextToClipboard(text)
await navigator.clipboard.writeText(text) if (!result.ok) {
} catch (err) { console.error('Failed to copy to clipboard:', result.error)
console.error('Failed to copy to clipboard:', err)
} }
} }
+20 -14
View File
@@ -27,6 +27,7 @@ import {
RiFileCopyLine, RiFileCopyLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { import {
DropdownMenu, DropdownMenu,
@@ -447,8 +448,13 @@ const FileRow: React.FC<FileRowProps> = ({
)} )}
<DropdownMenuItem onClick={(e) => { <DropdownMenuItem onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void navigator.clipboard.writeText(node.path); void copyTextToClipboard(node.path).then((result) => {
toast.success('Path copied'); if (result.ok) {
toast.success('Path copied');
return;
}
toast.error('Copy failed');
});
}}> }}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path <RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem> </DropdownMenuItem>
@@ -2049,8 +2055,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(fileContent);
await navigator.clipboard.writeText(fileContent); if (result.ok) {
setCopiedContent(true); setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) { if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current); window.clearTimeout(copiedContentTimeoutRef.current);
@@ -2058,7 +2064,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
copiedContentTimeoutRef.current = window.setTimeout(() => { copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false); setCopiedContent(false);
}, 1200); }, 1200);
} catch { } else {
toast.error('Copy failed'); toast.error('Copy failed');
} }
}} }}
@@ -2079,8 +2085,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(displaySelectedPath);
await navigator.clipboard.writeText(displaySelectedPath); if (result.ok) {
setCopiedPath(true); setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) { if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current); window.clearTimeout(copiedPathTimeoutRef.current);
@@ -2088,7 +2094,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
copiedPathTimeoutRef.current = window.setTimeout(() => { copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false); setCopiedPath(false);
}, 1200); }, 1200);
} catch { } else {
toast.error('Copy failed'); toast.error('Copy failed');
} }
}} }}
@@ -2437,8 +2443,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(fileContent);
await navigator.clipboard.writeText(fileContent); if (result.ok) {
setCopiedContent(true); setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) { if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current); window.clearTimeout(copiedContentTimeoutRef.current);
@@ -2446,7 +2452,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
copiedContentTimeoutRef.current = window.setTimeout(() => { copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false); setCopiedContent(false);
}, 1200); }, 1200);
} catch { } else {
toast.error('Copy failed'); toast.error('Copy failed');
} }
}} }}
@@ -2467,8 +2473,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(displaySelectedPath);
await navigator.clipboard.writeText(displaySelectedPath); if (result.ok) {
setCopiedPath(true); setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) { if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current); window.clearTimeout(copiedPathTimeoutRef.current);
@@ -2476,7 +2482,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
copiedPathTimeoutRef.current = window.setTimeout(() => { copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false); setCopiedPath(false);
}, 1200); }, 1200);
} catch { } else {
toast.error('Copy failed'); 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 type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { copyTextToClipboard } from '@/lib/clipboard';
import { import {
useGitStore, useGitStore,
useGitStatus, useGitStatus,
@@ -473,14 +474,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
const [stashDialogBranch, setStashDialogBranch] = React.useState(''); const [stashDialogBranch, setStashDialogBranch] = React.useState('');
const handleCopyCommitHash = React.useCallback((hash: string) => { const handleCopyCommitHash = React.useCallback((hash: string) => {
navigator.clipboard void copyTextToClipboard(hash).then((result) => {
.writeText(hash) if (result.ok) {
.then(() => {
toast.success('Commit hash copied'); toast.success('Commit hash copied');
}) return;
.catch(() => { }
toast.error('Failed to copy'); toast.error('Failed to copy');
}); });
}, []); }, []);
const handleToggleCommit = React.useCallback((hash: string) => { const handleToggleCommit = React.useCallback((hash: string) => {
@@ -21,6 +21,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
const normalize = (value: string): string => { const normalize = (value: string): string => {
if (!value) return ''; if (!value) return '';
@@ -428,8 +429,8 @@ export const PlanView: React.FC = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(content);
await navigator.clipboard.writeText(content); if (result.ok) {
setCopiedContent(true); setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) { if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current); window.clearTimeout(copiedContentTimeoutRef.current);
@@ -437,7 +438,7 @@ export const PlanView: React.FC = () => {
copiedContentTimeoutRef.current = window.setTimeout(() => { copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false); setCopiedContent(false);
}, 1200); }, 1200);
} catch { } else {
// ignored // ignored
} }
}} }}
@@ -455,8 +456,8 @@ export const PlanView: React.FC = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { const result = await copyTextToClipboard(displayPath ?? resolvedPath);
await navigator.clipboard.writeText(displayPath ?? resolvedPath); if (result.ok) {
setCopiedPath(true); setCopiedPath(true);
if (copiedTimeoutRef.current !== null) { if (copiedTimeoutRef.current !== null) {
window.clearTimeout(copiedTimeoutRef.current); window.clearTimeout(copiedTimeoutRef.current);
@@ -464,7 +465,7 @@ export const PlanView: React.FC = () => {
copiedTimeoutRef.current = window.setTimeout(() => { copiedTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false); setCopiedPath(false);
}, 1200); }, 1200);
} catch { } else {
// ignored // ignored
} }
}} }}
@@ -8,6 +8,7 @@ import {
} from '@remixicon/react'; } from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; 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'); toast.error('No worktree path available');
return; return;
} }
navigator.clipboard void copyTextToClipboard(selectedSession.path).then((result) => {
.writeText(selectedSession.path) if (result.ok) {
.then(() => {
toast.success('Worktree path copied'); toast.success('Worktree path copied');
}) return;
.catch(() => { }
toast.error('Failed to copy path'); toast.error('Failed to copy path');
}); });
}, [selectedSession?.path]); }, [selectedSession?.path]);
const handleRemoveSelectedWorktree = React.useCallback(async () => { 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 { opencodeClient } from '@/lib/opencode/client';
import { checkIsGitRepository } from '@/lib/gitApi'; import { checkIsGitRepository } from '@/lib/gitApi';
import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
export interface DebugMessageInfo { export interface DebugMessageInfo {
messageId: string; messageId: string;
@@ -373,13 +374,14 @@ export const debugUtils = {
return JSON.stringify(report, null, 2); return JSON.stringify(report, null, 2);
}, },
async copyTextToClipboard(text: string) {
return copyPlainTextToClipboard(text);
},
async copyDiagnosticsReport() { async copyDiagnosticsReport() {
const report = await this.buildDiagnosticsReport(); const report = await this.buildDiagnosticsReport();
if (typeof navigator !== 'undefined' && navigator.clipboard) { const result = await this.copyTextToClipboard(report);
await navigator.clipboard.writeText(report); return { ...result, report } as const;
return { ok: true, report } as const;
}
return { ok: false, report } as const;
}, },
checkLastMessage() { checkLastMessage() {