Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 117 additions & 1 deletion packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof createRoot>[] = [];

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,
Expand Down Expand Up @@ -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<void>) {
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(
<StrictMode>
<LocaleProvider locale="en">
<TurnView
turn={{ ...turnWith([{ kind: 'text', text, messageId: 'answer-1', live: false }]), status: 'completed' }}
footerActions={[{ id: 'copy', label: 'Copy', enabled: true }]}
/>
</LocaleProvider>
</StrictMode>,
));
const button = container.querySelector<HTMLButtonElement>('[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<void>();
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<void>();
const writeText = t.mock.fn(async (_text: string): Promise<void> => {});
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<void> => {
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<void>();
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');
});
48 changes: 4 additions & 44 deletions packages/ui/src/chat-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -889,52 +888,13 @@ function TurnFooterActions(props: {
assistantText?: string;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
const [copyPhase, setCopyPhase] = useState<ClipboardCopyPhase | null>(null);
const copyPendingRef = useRef(false);
const copyResetTimerRef = useRef<number | null>(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<ClipboardCopyPhase, 'pending'>) {
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
Expand Down
5 changes: 2 additions & 3 deletions packages/ui/src/clipboard-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down