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