diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 4f6b66e0c0..22e2935368 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { afterEach, test } from 'node:test'; -import { act } from 'react'; +import { act, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { TurnView } from '../chat-turn.js'; @@ -39,11 +39,15 @@ const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; }).IS_REACT_ACT_ENVIRONMENT; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const mountedRoots: ReturnType[] = []; afterEach(async () => { // Unmount before restoring globals: React's cleanup reads `document`. for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard); + else Reflect.deleteProperty(navigator, 'clipboard'); Object.assign(globalThis, { ...originalGlobals, IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, @@ -404,3 +408,115 @@ test('announces settlement when a persisted answer is promoted to a completed li ); assert.deepEqual(settled, ['answer-1'], 'staying settled does not re-announce'); }); + +async function renderCopyFooter(writeText: (text: string) => Promise) { + const { container, root } = domRoot(); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + // A secret-shaped value distinguishes original-text copy from the hook's default redaction. + const text = 'Authorization: Bearer sk-test-1234567890abcdef'; + await act(async () => root.render( + + + + + , + )); + const button = container.querySelector('[data-action="copy"]'); + assert.ok(button, 'the completed answer exposes its real footer copy action'); + return { root, button, text }; +} + +test('footer copy preserves raw text, blocks overlapping writes and resets success after 1400ms', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = Promise.withResolvers(); + const writeText = t.mock.fn((_text: string) => pending.promise); + const { button, text } = await renderCopyFooter(writeText); + + await act(async () => { + button.click(); + button.click(); + }); + assert.equal(writeText.mock.callCount(), 1); + assert.equal(writeText.mock.calls[0]?.arguments[0], text); + assert.equal(button.getAttribute('data-copy-feedback'), 'pending'); + assert.equal(button.getAttribute('data-pending'), 'true'); + + await act(async () => pending.resolve()); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + assert.equal(button.hasAttribute('data-pending'), false); + await act(async () => t.mock.timers.tick(1399)); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + await act(async () => t.mock.timers.tick(1)); + assert.equal(button.hasAttribute('data-copy-feedback'), false); +}); + +test('footer copy cancels the previous reset and restarts feedback after another copy', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = Promise.withResolvers(); + const writeText = t.mock.fn(async (_text: string): Promise => {}); + const { button } = await renderCopyFooter(writeText); + await act(async () => button.click()); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + await act(async () => t.mock.timers.tick(500)); + + writeText.mock.mockImplementation(() => pending.promise); + await act(async () => button.click()); + assert.equal(writeText.mock.callCount(), 2); + await act(async () => t.mock.timers.tick(900)); + assert.equal(button.getAttribute('data-copy-feedback'), 'pending', 'the first reset must not clear the second write'); + + await act(async () => pending.resolve()); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + await act(async () => t.mock.timers.tick(1399)); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + await act(async () => t.mock.timers.tick(1)); + assert.equal(button.hasAttribute('data-copy-feedback'), false); +}); + +test('footer copy cancels its active reset timer on unmount', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { root, button } = await renderCopyFooter(async () => {}); + const setTimeout = t.mock.method(window, 'setTimeout'); + await act(async () => button.click()); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); + const reset = setTimeout.mock.calls.find((call) => call.arguments[1] === 1400); + assert.ok(reset, 'successful copying schedules a feedback reset'); + await act(async () => t.mock.timers.tick(500)); + + const clearTimeout = t.mock.method(window, 'clearTimeout'); + await act(async () => root.unmount()); + mountedRoots.splice(mountedRoots.indexOf(root), 1); + assert.ok(clearTimeout.mock.calls.some((call) => call.arguments[0] === reset.result), 'unmount cancels the scheduled reset'); +}); + +test('footer copy reports clipboard failure and allows a successful retry', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const writeText = t.mock.fn(async (_text: string): Promise => { + throw new Error('Clipboard unavailable'); + }); + const { button } = await renderCopyFooter(writeText); + await act(async () => button.click()); + assert.equal(button.getAttribute('data-copy-feedback'), 'failed'); + await act(async () => t.mock.timers.tick(1400)); + assert.equal(button.hasAttribute('data-copy-feedback'), false); + + writeText.mock.mockImplementation(async (_text: string) => {}); + await act(async () => button.click()); + assert.equal(writeText.mock.callCount(), 2); + assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); +}); + +test('footer copy does not schedule feedback after it unmounts with a write pending', async (t) => { + const pending = Promise.withResolvers(); + const { root, button } = await renderCopyFooter(() => pending.promise); + await act(async () => button.click()); + await act(async () => root.unmount()); + mountedRoots.splice(mountedRoots.indexOf(root), 1); + + const setTimeout = t.mock.method(window, 'setTimeout'); + await act(async () => pending.resolve()); + assert.equal(setTimeout.mock.callCount(), 0, 'a late clipboard completion must not start a reset timer'); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e25e695637..454cd0c92e 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -18,9 +18,8 @@ */ import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; -import { useMountedRef } from './use-mounted-ref.js'; import { ICON_SIZE, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; -import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; +import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { formatTurnDuration, turnAbortStatusLabel } from './chat-display-helpers.js'; import { formatAbsoluteTimestamp } from '@maka/core/relative-time'; @@ -889,52 +888,13 @@ function TurnFooterActions(props: { assistantText?: string; }) { const copy = getConversationCopy(useUiLocale()).messages; - const [copyPhase, setCopyPhase] = useState(null); - const copyPendingRef = useRef(false); - const copyResetTimerRef = useRef(null); - const copyMountedRef = useMountedRef(); - - function clearCopyResetTimer() { - if (copyResetTimerRef.current === null) return; - window.clearTimeout(copyResetTimerRef.current); - copyResetTimerRef.current = null; - } - - useEffect(() => { - return () => { - clearCopyResetTimer(); - }; - }, []); - - function settleCopy(phase: Exclude) { - if (!copyMountedRef.current) return; - setCopyPhase(phase); - copyResetTimerRef.current = window.setTimeout(() => { - if (!copyMountedRef.current) return; - setCopyPhase(null); - copyResetTimerRef.current = null; - }, 1400); - } - - async function copyAssistantText() { - if (!props.assistantText || copyPendingRef.current) return; - copyPendingRef.current = true; - clearCopyResetTimer(); - setCopyPhase('pending'); - try { - await navigator.clipboard.writeText(props.assistantText); - settleCopy('copied'); - } catch { - settleCopy('failed'); - } finally { - copyPendingRef.current = false; - } - } + const copyFeedback = useClipboardCopyFeedback(1400, { redact: false }); + const copyPhase = copyFeedback.phaseFor('answer'); async function handleClick(action: TurnFooterActionMeta) { if (!action.enabled) return; if (action.id === 'copy') { - await copyAssistantText(); + await copyFeedback.copy('answer', props.assistantText ?? ''); return; } if (action.id === 'info') return; // tooltip-only meta display, no action diff --git a/packages/ui/src/clipboard-feedback.ts b/packages/ui/src/clipboard-feedback.ts index 3e1d4acd0b..ad127069a3 100644 --- a/packages/ui/src/clipboard-feedback.ts +++ b/packages/ui/src/clipboard-feedback.ts @@ -23,9 +23,8 @@ * PR-UI-LIB-EXTRACT-7 (WAWQAQ msg `510fef52`, round 8/10): pulled * out of `components.tsx`. The hook is consumed at three sites * inside `@maka/ui` (message metadata copy, ToolActivity, and the - * structured preview); the `phase` type - * is also referenced by `TurnFooterActions` which keeps its own - * inline copy-feedback state. None of these are part of the + * structured preview). `TurnFooterActions` also uses this hook + * for its inline copy-feedback state. None of these are part of the * public API. * * byte-for-byte equivalent; behavior unchanged.