cc-haha/desktop/src/components/layout/StartupErrorView.tsx
程序员阿江(Relakkes) 5898e02e01 fix: keep desktop copy actions reliable
Several desktop copy entry points still called navigator.clipboard directly, which can fail or report success inconsistently in Tauri and browser test contexts. Route those copy actions through the existing textarea execCommand fallback helper and cover the fallback paths for startup diagnostics, markdown copy controls, and workspace file paths.

Constraint: Clipboard API is not reliable in every desktop/browser context.

Rejected: Leave direct navigator.clipboard calls in isolated components | repeats the diagnostics copy failure mode and creates inconsistent copy behavior.

Confidence: high

Scope-risk: narrow

Directive: New desktop copy controls should use copyTextToClipboard or CopyButton instead of calling navigator.clipboard directly.

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- --run src/components/markdown/MarkdownRenderer.test.tsx src/components/layout/StartupErrorView.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/__tests__/diagnosticsSettings.test.tsx src/components/chat/MessageList.test.tsx

Tested: bun run check:desktop

Tested: bun run check:native

Tested: bun run check:coverage

Tested: bun run verify (passed=7 failed=0 skipped=3)

Not-tested: Live provider baseline; not required for this desktop-only clipboard fix.
2026-05-14 22:44:00 +08:00

106 lines
3.5 KiB
TypeScript

import { Copy, RefreshCw } from 'lucide-react'
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'
export function splitStartupError(error: string) {
const markerIndex = error.indexOf(LOG_MARKER)
if (markerIndex === -1) {
return {
message: error,
logs: '',
diagnostics: error,
}
}
const message = error.slice(0, markerIndex).trim()
const logs = error.slice(markerIndex + LOG_MARKER.length).trim()
return {
message,
logs,
diagnostics: `${message}\n\nRecent server logs:\n${logs}`,
}
}
type StartupErrorViewProps = {
error: string
}
export function StartupErrorView({ error }: StartupErrorViewProps) {
const t = useTranslation()
const { message, logs, diagnostics } = useMemo(() => splitStartupError(error), [error])
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
const ok = await copyTextToClipboard(diagnostics)
if (!ok) return
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-3xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{t('app.serverFailedHint')}
</p>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.startupError')}
</div>
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--color-error)]">
{message}
</pre>
</div>
{logs ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.serverLogs')}
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text-secondary)]">
{logs}
</pre>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
icon={<Copy className="h-4 w-4" aria-hidden="true" />}
onClick={handleCopy}
>
{copied ? t('app.copiedDiagnostics') : t('app.copyDiagnostics')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
icon={<RefreshCw className="h-4 w-4" aria-hidden="true" />}
onClick={() => window.location.reload()}
>
{t('common.retry')}
</Button>
</div>
<DoctorPanel compact />
</div>
</section>
</div>
)
}