You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by li...@apache.org on 2026/09/06 05:26:15 UTC
(maka) branch main updated: refactor(ui): reuse clipboard feedback for turn footer (#4891)
This is an automated email from the ASF dual-hosted git repository.
liugddx pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new c0229b0c6d refactor(ui): reuse clipboard feedback for turn footer (#4891)
c0229b0c6d is described below
commit c0229b0c6d1b9db2fc7a1f0708bd01e071ea64e8
Author: 爱喝可乐的派大猩猩 <72...@users.noreply.github.com>
AuthorDate: Sun Sep 6 13:26:10 2026 +0800
refactor(ui): reuse clipboard feedback for turn footer (#4891)
* refactor(ui): reuse clipboard feedback for turn footer
Refs #1404. Preserve raw answer copying, pending deduplication, failure feedback and the 1400ms reset while removing the duplicate footer state machine. Add real TurnView copy-action coverage.
Generated-by: OpenAI Codex
* test(ui): cover copy feedback timer cleanup
Cover a second copy before feedback resets and cancellation of an active reset timer on unmount.
Generated-by: OpenAI Codex
---
.../__tests__/chat-turn-answer-identity.test.tsx | 118 ++++++++++++++++++++-
packages/ui/src/chat-turn.tsx | 48 +--------
packages/ui/src/clipboard-feedback.ts | 5 +-
3 files changed, 123 insertions(+), 48 deletions(-)
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<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,
@@ -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');
+});
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<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
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.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org