merge: integrate reliable desktop copy fallback

Bring the detached worktree clipboard fix onto local main while preserving main's existing desktop responsiveness work.

Constraint: Local main and the detached worktree diverged from e070c4a0, so this requires a merge commit instead of a fast-forward.

Confidence: high

Scope-risk: narrow

Directive: Keep desktop copy controls on the shared fallback helper when adding new copy surfaces.

Tested: bun run verify (passed=7 failed=0 skipped=3) in /Users/nanmi/.codex/worktrees/ec25/claude-code-haha before merge

Not-tested: Post-merge full gate on local main; merge applies the verified six-file desktop patch onto a clean local main.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 22:44:26 +08:00
commit c39a8331c7
6 changed files with 163 additions and 27 deletions

View File

@ -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(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
try {
render(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
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,
})
}
})
})

View File

@ -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)
}

View File

@ -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(<MarkdownRenderer content={'<button data-copy-code="npm run verify">Copy</button>'} />)
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,
})
}
})
})

View File

@ -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) {

View File

@ -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,

View File

@ -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) {
<button
type="button"
role="menuitem"
onClick={() => copyWorkspacePath(fileContextMenu.path)}
onClick={() => void copyWorkspacePath(fileContextMenu.path)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">content_copy</span>