From 45a60cbafef2647d1ed58f3d300783b5be5ebc46 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 1 Aug 2026 01:49:57 -0700 Subject: [PATCH 1/2] fix(ui): fall back to legacy copy in insecure browser contexts navigator.clipboard only exists in secure contexts. Remote mode serves plain HTTP on a non-localhost host, so every bare navigator.clipboard.writeText call threw TypeError and copy buttons silently broke. Add copyTextToClipboard(text): Promise to packages/ui/utils/clipboard.ts: it tries the async Clipboard API (guarded against synchronous throws), falls back to the existing copy-event plus execCommand path, reports success as a boolean, and never throws. copyTextWithFallback now returns whether the copy happened and accepts an optional focusOwner; copyTextPreservingFocus keeps its exported signature and behavior unchanged. Route all bare call sites through the helper, preserving each site's UX: Copied states only flip on success, error toasts and console errors remain for the failure case, fire-and-forget sites stay fire-and-forget. GoalSetupSurface gains the fallback and keeps its error surface for the all-strategies-failed case. Add DOM-gated unit tests for the helper and register them in CI. Closes #1173 --- .github/workflows/test.yml | 1 + packages/editor/App.tsx | 13 +- packages/review-editor/App.tsx | 26 ++-- .../review-editor/components/CopyButton.tsx | 6 +- .../review-editor/components/FileTreeNode.tsx | 7 +- .../components/ReviewSidebar.tsx | 8 +- packages/ui/components/AnnotationToolbar.tsx | 16 +-- packages/ui/components/CodeFilePopout.tsx | 8 +- packages/ui/components/ExportModal.tsx | 8 +- packages/ui/components/MenuVersionSection.tsx | 8 +- packages/ui/components/OpenInAppButton.tsx | 7 +- packages/ui/components/Viewer.tsx | 8 +- packages/ui/components/blocks/CodeBlock.tsx | 8 +- packages/ui/components/blocks/TablePopout.tsx | 15 +-- .../ui/components/blocks/TableToolbar.tsx | 15 +-- .../goal-setup/GoalSetupSurface.tsx | 5 +- packages/ui/components/settings/HooksTab.tsx | 4 +- packages/ui/hooks/useArchive.ts | 3 +- packages/ui/utils/clipboard.test.ts | 125 ++++++++++++++++++ packages/ui/utils/clipboard.ts | 33 ++++- 20 files changed, 232 insertions(+), 92 deletions(-) create mode 100644 packages/ui/utils/clipboard.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2df339cc..aed0fe265 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,7 @@ jobs: packages/ui/annotationDraftPersistence.test.tsx packages/ui/codeAnnotationDraftPersistence.test.tsx packages/ui/components/html-viewer/srcdoc.test.ts + packages/ui/utils/clipboard.test.ts packages/ui/components/InlineMarkdown.resolveLinkedDoc.test.tsx packages/ui/components/MarkdownDiff.frozen.test.tsx packages/ui/components/MarkdownEditor.extensions.test.tsx diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4382b9146..34ab8ff21 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -25,6 +25,7 @@ import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator import { useAgents } from '@plannotator/ui/hooks/useAgents'; import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; import { storage } from '@plannotator/ui/utils/storage'; +import { copyTextToClipboard } from '@plannotator/ui/utils/clipboard'; import { configStore, useConfigValue } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; @@ -3829,10 +3830,9 @@ const App: React.FC = () => { // (utils/agentInstructions.ts) so it's easy to edit independently of UI code. const handleCopyAgentInstructions = async () => { const payload = buildPlanAgentInstructions(window.location.origin); - try { - await navigator.clipboard.writeText(payload); + if (await copyTextToClipboard(payload)) { toast.success('Agent instructions copied'); - } catch { + } else { toast.error('Failed to copy'); } }; @@ -3845,10 +3845,9 @@ const App: React.FC = () => { toast.error('Failed to create share link'); return; } - try { - await navigator.clipboard.writeText(url); + if (await copyTextToClipboard(url)) { toast.success('Share link copied'); - } catch { + } else { toast.error('Failed to copy'); } }; @@ -4726,7 +4725,7 @@ const App: React.FC = () => { onClose={() => setIsPanelOpen(false)} onQuickCopy={async () => { const output = getCurrentFeedbackPayload(); - await navigator.clipboard.writeText(wrapCopiedFeedback(output)); + await copyTextToClipboard(wrapCopiedFeedback(output)); }} onShare={canShareCurrentSession ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined} otherFileAnnotations={otherFileAnnotations} diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index ec97c9614..7fb8a2f8d 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -107,6 +107,7 @@ import { TextShimmer } from '@plannotator/ui/components/TextShimmer'; import type { PRMetadata } from '@plannotator/shared/pr-types'; import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; import { altKey } from '@plannotator/ui/utils/platform'; +import { copyTextToClipboard } from '@plannotator/ui/utils/clipboard'; import { TourDialog } from './components/tour/TourDialog'; import { DEMO_TOUR_ID } from './demoTour'; import { GuideScreen } from './components/guide/GuideScreen'; @@ -620,10 +621,9 @@ const ReviewApp: React.FC = () => { // module (utils/reviewAgentInstructions.ts) so it's easy to edit independently. const handleCopyAgentInstructions = useCallback(async () => { const payload = buildReviewAgentInstructions(window.location.origin); - try { - await navigator.clipboard.writeText(payload); + if (await copyTextToClipboard(payload)) { toast.success('Agent instructions copied'); - } catch { + } else { toast.error('Failed to copy'); } }, []); @@ -2483,12 +2483,11 @@ const ReviewApp: React.FC = () => { // Copy raw diff to clipboard const handleCopyDiff = useCallback(async () => { if (!diffData) return; - try { - await navigator.clipboard.writeText(diffData.rawPatch); + if (await copyTextToClipboard(diffData.rawPatch)) { setCopyRawDiffStatus('success'); setTimeout(() => setCopyRawDiffStatus('idle'), 2000); - } catch (err) { - console.error('Failed to copy:', err); + } else { + console.error('Failed to copy'); setCopyRawDiffStatus('error'); setTimeout(() => setCopyRawDiffStatus('idle'), 2000); } @@ -2523,12 +2522,11 @@ const ReviewApp: React.FC = () => { setShowNoAnnotationsDialog(true); return; } - try { - await navigator.clipboard.writeText(feedbackMarkdown); + if (await copyTextToClipboard(feedbackMarkdown)) { setCopyFeedback('Feedback copied!'); setTimeout(() => setCopyFeedback(null), 2000); - } catch (err) { - console.error('Failed to copy:', err); + } else { + console.error('Failed to copy'); setCopyFeedback('Failed to copy'); setTimeout(() => setCopyFeedback(null), 2000); } @@ -3661,8 +3659,8 @@ const ReviewApp: React.FC = () => {