diff --git a/.changeset/embedded-copy-prompt-clipboard.md b/.changeset/embedded-copy-prompt-clipboard.md new file mode 100644 index 00000000..0937de6c --- /dev/null +++ b/.changeset/embedded-copy-prompt-clipboard.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Fix the empty-state "copy prompt" rows not working when OK runs inside an embedded host iframe (e.g. Claude). The copy buttons called the async Clipboard API directly, which the host frame's Permissions-Policy refuses inside an iframe — the rejection was swallowed silently, so nothing copied and the row never showed "Copied". The copy now routes through the shared clipboard adapter, whose `execCommand` fallback succeeds under the click's user activation where the policy-gated async write is blocked. diff --git a/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx b/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx new file mode 100644 index 00000000..2506ce82 --- /dev/null +++ b/packages/app/src/components/empty-state/CopyablePromptList.dom.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; + +mock.module('@lingui/react/macro', () => ({ + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + useLingui: () => ({ t: renderLinguiTemplate }), +})); + +const { CopyablePromptList } = await import('./CopyablePromptList'); + +describe('CopyablePromptList', () => { + beforeEach(() => { + Reflect.deleteProperty(globalThis, 'okDesktop'); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + }); + afterEach(() => { + cleanup(); + Reflect.deleteProperty(globalThis.document, 'execCommand'); + }); + + test('flips a row to "Copied" when the clipboard write resolves', async () => { + const writeText = mock(() => Promise.resolve()); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + render(); + + const button = screen.getByTestId('copy-prompt-button-competitor-research'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).toContain( + 'Copied', + ); + }); + expect(writeText).toHaveBeenCalledTimes(1); + }); + + test('falls back to execCommand and still copies when embedded-iframe policy refuses the async write', async () => { + const writeText = mock(() => + Promise.reject( + Object.assign(new Error('blocked because of a permissions policy'), { + name: 'NotAllowedError', + }), + ), + ); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const execCommand = mock(() => true); + Object.defineProperty(globalThis.document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + render(); + + fireEvent.click(screen.getByTestId('copy-prompt-button-competitor-research')); + + await waitFor(() => { + expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).toContain( + 'Copied', + ); + }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + test('does not flip to "Copied" when every clipboard path is refused', async () => { + const writeText = mock(() => + Promise.reject(Object.assign(new Error('blocked'), { name: 'NotAllowedError' })), + ); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const execCommand = mock(() => false); + Object.defineProperty(globalThis.document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + render(); + fireEvent.click(screen.getByTestId('copy-prompt-button-competitor-research')); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy')); + expect(writeText).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('copy-prompt-button-competitor-research').textContent).not.toContain( + 'Copied', + ); + }); +}); diff --git a/packages/app/src/components/empty-state/CopyablePromptList.tsx b/packages/app/src/components/empty-state/CopyablePromptList.tsx index 7c72dacd..49ec9d01 100644 --- a/packages/app/src/components/empty-state/CopyablePromptList.tsx +++ b/packages/app/src/components/empty-state/CopyablePromptList.tsx @@ -6,6 +6,7 @@ import { useCreateSuggestions, } from '@/components/empty-state/use-create-suggestions'; import { Button } from '@/components/ui/button'; +import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter'; import { cn } from '@/lib/utils'; interface CopyablePromptListProps { @@ -22,9 +23,7 @@ export function CopyablePromptList({ scenario, className }: CopyablePromptListPr useEffect(() => () => clearTimeout(resetTimerRef.current), []); function handleCopy(id: string, prompt: string) { - if (!navigator.clipboard) return; - void navigator.clipboard - .writeText(prompt) + void scheduleClipboardWrite(prompt) .then(() => { setCopiedId(id); clearTimeout(resetTimerRef.current);