diff --git a/desktop/src/components/layout/StartupErrorView.test.tsx b/desktop/src/components/layout/StartupErrorView.test.tsx
index 757a63c2..007e4bde 100644
--- a/desktop/src/components/layout/StartupErrorView.test.tsx
+++ b/desktop/src/components/layout/StartupErrorView.test.tsx
@@ -19,28 +19,47 @@ describe('splitStartupError', () => {
})
describe('StartupErrorView', () => {
- it('shows diagnostics and copies the full payload', async () => {
- const writeText = vi.fn().mockResolvedValue(undefined)
+ it('shows diagnostics and copies the full payload with the legacy fallback', async () => {
+ const originalClipboard = navigator.clipboard
+ const originalExecCommand = document.execCommand
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: vi.fn().mockReturnValue(true),
+ })
+ const execCommand = vi.mocked(document.execCommand)
+ const writeText = vi.fn().mockRejectedValue(new Error('clipboard blocked'))
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
- render(
- ,
- )
+ try {
+ render(
+ ,
+ )
- expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
- expect(screen.getByText('startup failed')).toBeInTheDocument()
- expect(screen.getByText('[stderr] boom')).toBeInTheDocument()
+ expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
+ expect(screen.getByText('startup failed')).toBeInTheDocument()
+ expect(screen.getByText('[stderr] boom')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' }))
+ fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' }))
- await waitFor(() => {
+ await waitFor(() => {
+ expect(execCommand).toHaveBeenCalledWith('copy')
+ })
expect(writeText).toHaveBeenCalledWith(
'startup failed\n\nRecent server logs:\n[stderr] boom',
)
- })
- expect(screen.getByText('已复制')).toBeInTheDocument()
+ expect(screen.getByText('已复制')).toBeInTheDocument()
+ } finally {
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: originalExecCommand,
+ })
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: originalClipboard,
+ })
+ }
})
})
diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx
index 88a820ea..134cf4ee 100644
--- a/desktop/src/components/layout/StartupErrorView.tsx
+++ b/desktop/src/components/layout/StartupErrorView.tsx
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
import { DoctorPanel } from '../doctor/DoctorPanel'
+import { copyTextToClipboard } from '../chat/clipboard'
const LOG_MARKER = '\n\nRecent server logs:\n'
@@ -35,7 +36,9 @@ export function StartupErrorView({ error }: StartupErrorViewProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
- await navigator.clipboard?.writeText(diagnostics)
+ const ok = await copyTextToClipboard(diagnostics)
+ if (!ok) return
+
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx
index b47bd5b8..4692a7ff 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('../chat/CodeViewer', () => ({
@@ -126,4 +126,42 @@ describe('MarkdownRenderer', () => {
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
})
+
+ it('copies enhanced markdown button text with the legacy clipboard fallback', async () => {
+ const originalClipboard = navigator.clipboard
+ const originalExecCommand = document.execCommand
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: vi.fn().mockReturnValue(true),
+ })
+ const execCommand = vi.mocked(document.execCommand)
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
+ },
+ })
+ const writeText = vi.mocked(navigator.clipboard.writeText)
+
+ try {
+ render(Copy'} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
+
+ await waitFor(() => {
+ expect(execCommand).toHaveBeenCalledWith('copy')
+ })
+ expect(writeText).toHaveBeenCalledWith('npm run verify')
+ expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
+ } finally {
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: originalExecCommand,
+ })
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: originalClipboard,
+ })
+ }
+ })
})
diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx
index f710de05..eac69a50 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.tsx
@@ -3,6 +3,7 @@ import DOMPurify from 'dompurify'
import { marked, type Tokens } from 'marked'
import { CodeViewer } from '../chat/CodeViewer'
import { MermaidRenderer } from '../chat/MermaidRenderer'
+import { copyTextToClipboard } from '../chat/clipboard'
type Props = {
content: string
@@ -201,16 +202,14 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
const text = button.getAttribute('data-copy-code')
if (!text) return
- try {
- await navigator.clipboard.writeText(text)
- const original = button.textContent
- button.textContent = 'Copied'
- window.setTimeout(() => {
- button.textContent = original
- }, 1500)
- } catch {
- // Ignore clipboard errors
- }
+ const copied = await copyTextToClipboard(text)
+ if (!copied) return
+
+ const original = button.textContent
+ button.textContent = 'Copied'
+ window.setTimeout(() => {
+ button.textContent = original
+ }, 1500)
}, [])
if (codeBlocks.length === 0) {
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx
index a0873feb..5063b41b 100644
--- a/desktop/src/components/workspace/WorkspacePanel.test.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx
@@ -1194,6 +1194,82 @@ describe('WorkspacePanel', () => {
])
})
+ it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => {
+ const originalClipboard = navigator.clipboard
+ const originalExecCommand = document.execCommand
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: vi.fn().mockReturnValue(true),
+ })
+ const execCommand = vi.mocked(document.execCommand)
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
+ },
+ })
+ const writeText = vi.mocked(navigator.clipboard.writeText)
+
+ try {
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-copy-file': {
+ isOpen: true,
+ activeView: 'all',
+ },
+ },
+ statusBySession: {
+ ...state.statusBySession,
+ 'session-copy-file': {
+ state: 'ok',
+ workDir: '/repo',
+ repoName: 'repo',
+ branch: 'main',
+ isGitRepo: true,
+ changedFiles: [],
+ },
+ },
+ treeBySessionPath: {
+ ...state.treeBySessionPath,
+ 'session-copy-file': {
+ '': {
+ state: 'ok',
+ path: '',
+ entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
+ },
+ },
+ },
+ }))
+
+ const view = await renderPanel('session-copy-file')
+
+ await act(() => {
+ fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
+ clientX: 260,
+ clientY: 80,
+ })
+ })
+
+ await clickElement(view.getByRole('menuitem', { name: 'Copy path' }))
+
+ await waitFor(() => {
+ expect(execCommand).toHaveBeenCalledWith('copy')
+ })
+ expect(writeText).toHaveBeenCalledWith('/repo/src/App.tsx')
+ } finally {
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: originalExecCommand,
+ })
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: originalClipboard,
+ })
+ }
+ })
+
it('adds a line comment from a code preview to the chat context', async () => {
await setWorkspaceState((state) => ({
...state,
diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx
index ada3f69e..020e3bdd 100644
--- a/desktop/src/components/workspace/WorkspacePanel.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.tsx
@@ -16,6 +16,7 @@ import {
} from '../../stores/workspacePanelStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
+import { copyTextToClipboard } from '../chat/clipboard'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import {
getFileExtension,
@@ -1066,9 +1067,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
setPreviewTabContextMenu(null)
}
- const copyWorkspacePath = (path: string) => {
+ const copyWorkspacePath = async (path: string) => {
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
- void navigator.clipboard?.writeText(resolvedPath)
+ await copyTextToClipboard(resolvedPath)
setFileContextMenu(null)
}
@@ -1460,7 +1461,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {