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
10 changes: 5 additions & 5 deletions packages/web-shell/client/components/MessageTimestamp.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useState, type ReactNode } from 'react';
import { useCallback, type ReactNode } from 'react';
import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../utils/clipboard';
import { useCopiedFlash } from '../hooks/useCopiedFlash';
import styles from './MessageTimestamp.module.css';

interface MessageTimestampProps {
Expand All @@ -29,16 +30,15 @@ export function MessageTimestamp({
copyText,
copyTitle = 'Copy',
}: MessageTimestampProps) {
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash();
const handleCopy = useCallback(() => {
if (!copyText) return;
void writeClipboardText(copyText)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
flashCopied();
})
.catch(warnClipboardWriteFailure);
}, [copyText]);
}, [copyText, flashCopied]);
if (timestamp === undefined && !copyText && !toolGroupSpacing) {
return <>{children}</>;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/web-shell/client/components/dialogs/GitLogDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import { timeAgo } from '../../utils/timeAgo';
import { DialogShell } from './DialogShell';
import styles from './GitLogDialog.module.css';
Expand Down Expand Up @@ -60,14 +61,13 @@ function CommitRow({
const [detail, setDetail] = useState<DaemonGitCommitDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash(1500);
const cancelledRef = useRef(false);

const copySha = () => {
void writeClipboardText(entry.sha)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
flashCopied();
})
.catch(warnClipboardWriteFailure);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,41 @@ describe('AssistantMessage markdown tables', () => {
});
});

describe('AssistantMessage copy reset timer', () => {
it('leaves no pending reset timer behind on unmount', async () => {
vi.useFakeTimers();
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
try {
const container = render(
<AssistantMessage content="copy me" showFooterActions />,
);
const button = container.querySelector<HTMLButtonElement>(
'button[title="Copy"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
// The 2s reset is pending; unmounting must clear it, or it fires
// after the file's environment is torn down and the unit suites'
// unhandled-error gate turns an all-green run red.
expect(vi.getTimerCount()).toBeGreaterThan(0);
const { root, container: mountedContainer } = mounted.pop()!;
act(() => root.unmount());
mountedContainer.remove();
expect(vi.getTimerCount()).toBe(0);
} finally {
if (descriptor) {
Object.defineProperty(navigator, 'clipboard', descriptor);
}
}
});
});

describe('AssistantMessage copy without the async Clipboard API (issue #9485)', () => {
it('falls back to execCommand and still shows the copied state', async () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import type { DaemonSessionGenerationEvent } from '@qwen-code/sdk/daemon';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
Expand Down Expand Up @@ -39,7 +40,7 @@ export const AssistantMessage = memo(function AssistantMessage({
}: AssistantMessageProps) {
const { t } = useI18n();
const { renderAssistantTurnFooter } = useWebShellCustomization();
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash();
const [branchPending, setBranchPending] = useState(false);
const showFooter = !!content && !isStreaming && showFooterActions;
const customFooter = useMemo(
Expand All @@ -63,11 +64,10 @@ export const AssistantMessage = memo(function AssistantMessage({
const handleCopy = useCallback(() => {
void writeClipboardText(content)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
flashCopied();
})
.catch(warnClipboardWriteFailure);
}, [content]);
}, [content, flashCopied]);
return (
<div className={styles.message}>
{content && (
Expand Down
11 changes: 5 additions & 6 deletions packages/web-shell/client/components/messages/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import type { Components, Options } from 'react-markdown';
import { isMarkdownFenceClosed } from '@datafe-open/markdown-chart';
Expand Down Expand Up @@ -192,7 +193,7 @@ function MermaidBlock({ code }: { code: string }) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram');
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash();
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
Expand Down Expand Up @@ -321,8 +322,7 @@ function MermaidBlock({ code }: { code: string }) {
const handleCopy = () => {
void writeClipboardText(code)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
flashCopied();
})
.catch(warnClipboardWriteFailure);
};
Expand Down Expand Up @@ -432,7 +432,7 @@ function CodeBlock({
const { t } = useI18n();
const appTheme = useTheme();
const [html, setHtml] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash();

const { label, lang, resolvedLang } = resolveFenceLanguage(
extractRawFenceLanguage(className),
Expand Down Expand Up @@ -501,8 +501,7 @@ function CodeBlock({
const handleCopy = () => {
void writeClipboardText(code)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
flashCopied();
})
.catch(warnClipboardWriteFailure);
};
Expand Down
10 changes: 5 additions & 5 deletions packages/web-shell/client/components/messages/SystemMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useState } from 'react';
import { memo, useCallback } from 'react';
import {
CheckIcon,
CircleCheckIcon,
Expand All @@ -12,6 +12,7 @@ import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import {
ContextUsageMessage,
parseContextUsageMessage,
Expand Down Expand Up @@ -112,15 +113,14 @@ export const SystemMessage = memo(function SystemMessage({
onRetryClick,
}: SystemMessageProps) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const [copied, flashCopied] = useCopiedFlash();
const handleCopy = useCallback(() => {
void writeClipboardText(content)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
flashCopied();
})
.catch(warnClipboardWriteFailure);
}, [content]);
}, [content, flashCopied]);
if (source === 'mid_turn_message_injected') {
return (
<UserMessage
Expand Down
79 changes: 79 additions & 0 deletions packages/web-shell/client/hooks/useCopiedFlash.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useCopiedFlash } from './useCopiedFlash';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

let root: Root;
let container: HTMLDivElement;
let latest: [boolean, () => void] | undefined;

function Probe({ resetMs }: { resetMs?: number }) {
latest = useCopiedFlash(resetMs);
return null;
}
Comment on lines +19 to +22

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-4: The hook's resetMs parameter — used in production by GitLogDialog.tsx as useCopiedFlash(1500) — is exercised by no test anywhere, even though this Probe already declares a resetMs prop that no test ever sets. Every test mounts the probe without resetMs, so only the 2000ms default path runs: hardcoding 2000 in the hook's setTimeout keeps the entire suite green (verified mutant: 27/27 pass), so GitLogDialog's commit-SHA copy feedback would silently stretch from 1.5s to 2s with no failing test to notice — and the unused prop tells the next reader that custom delays are covered when they are not.

Witness:

hardcoded-2000 mutant: Tests 1 failed | 27 passed
  (the 1 failure is a new resetMs=500 probe: expected true to be false;
   all 27 pre-existing tests green)
intact hook: the same probe passes
GitLogDialog.test.tsx grep copied|1500|advanceTimers|clipboard: zero matches

Suggested fix — re-render the probe with a custom delay (the harness root is reusable):

it('resets after a custom delay', () => {
  act(() => {
    root.render(<Probe resetMs={500} />);
  });
  act(() => latest![1]());
  expect(latest![0]).toBe(true);
  act(() => {
    vi.advanceTimersByTime(499);
  });
  expect(latest![0]).toBe(true);
  act(() => {
    vi.advanceTimersByTime(1);
  });
  expect(latest![0]).toBe(false);
});

The new case itself is the acceptance criterion — replacing resetMs with a hardcoded 2000 in useCopiedFlash.ts must make it fail.

中文说明

hook 的 resetMs 参数——生产环境里 GitLogDialog.tsxuseCopiedFlash(1500) 使用——没有任何测试覆盖,尽管这个 Probe 已经声明了一个从未被任何测试赋值的 resetMs prop。所有测试挂载探针时都不传 resetMs,因此只有 2000ms 默认路径被执行:把 2000 硬编码进 setTimeout 后整个套件依然全绿(已验证的变异体:27/27 通过),Git log 对话框的复制反馈会从 1.5 秒悄悄拉长到 2 秒而无任何测试察觉——未使用的 prop 还会让后来的读者误以为自定义时长已有覆盖。

建议修复:给探针传入自定义时长重新渲染(见上方代码,harness 的 root 可复用)。新增用例本身就是验收标准——把 useCopiedFlash.ts 里的 resetMs 替换成硬编码 2000 必须让它变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)


beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
latest = undefined;
act(() => {
root.render(<Probe />);
});
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.useRealTimers();
});

describe('useCopiedFlash', () => {
it('flashes and resets after the delay', () => {
act(() => latest![1]());
expect(latest![0]).toBe(true);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(latest![0]).toBe(false);
});

it('restarts the reset window on a re-flash', () => {
act(() => latest![1]());
act(() => {
vi.advanceTimersByTime(1500);
});
act(() => latest![1]());
act(() => {
vi.advanceTimersByTime(1500);
});
// The older reset must not cut the newer feedback short.
expect(latest![0]).toBe(true);
act(() => {
vi.advanceTimersByTime(500);
});
expect(latest![0]).toBe(false);
});

it('clears the pending reset on unmount', () => {
act(() => latest![1]());
act(() => {
root.unmount();
});
// A leaked timer would fire after the test environment is gone and
// fail the run through the unhandled-error gate.
expect(vi.getTimerCount()).toBe(0);
});
});
30 changes: 30 additions & 0 deletions packages/web-shell/client/hooks/useCopiedFlash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { useCallback, useEffect, useRef, useState } from 'react';

/**
* The transient "copied" feedback every copy button shows: `flash()` turns
* the flag on and schedules the reset. The pending reset is cleared on
* unmount — a leaked timer fires after a test file's environment is torn
* down and, since the unit suites fail on unhandled errors, turns an
* all-green run red (`window is not defined` out of the reset callback).
* A re-flash restarts the window instead of letting the older reset cut
* the newer feedback short.
*/
export function useCopiedFlash(
resetMs = 2000,
): [copied: boolean, flash: () => void] {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
const flash = useCallback(() => {
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), resetMs);
}, [resetMs]);
Comment on lines +24 to +28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: flash() invoked after the component has unmounted schedules a reset timer that the already-run unmount cleanup never clears, so the hook's documented guarantee — the pending reset is cleared on unmount — does not hold on the async entry path all six call sites use. Every call site invokes flashCopied() from writeClipboardText(...).then(...), and clipboard.ts documents that writeText can stay pending until the user answers the clipboard permission prompt. If the user clicks Copy and the component unmounts while the prompt is open (navigation, closing the Git Log dialog), the one-shot cleanup has already run when the promise settles, and flash() schedules an orphan setTimeout(() => setCopied(false), resetMs) that survives teardown — the same leaked-timer class this hook exists to prevent. That is benign in production today (the orphan callback is a setCopied no-op), but any future test that unmounts before the promise settles re-creates the post-teardown failure mode this PR exists to kill, since dangerouslyIgnoreUnhandledErrors is false on Linux CI.

Witness:

probe (fake timers: render → unmount → invoke captured flash()):
intact code:          timers pending after post-unmount flash(): 1
with mounted-ref fix: timers pending after post-unmount flash(): 0
useCopiedFlash.test.tsx 3/3 green in both runs

Suggested fix — track mounted state inside the hook and bail in flash:

const mountedRef = useRef(true);
useEffect(() => {
  mountedRef.current = true;
  return () => {
    mountedRef.current = false;
    window.clearTimeout(timerRef.current);
  };
}, []);
const flash = useCallback(() => {
  if (!mountedRef.current) return;
  setCopied(true);
  ...
}, [resetMs]);

Note that call sites list flashCopied in their own useCallback deps (e.g. MessageTimestamp.tsx }, [copyText, flashCopied]); in this diff), so the fix must keep flash referentially stable across renders — a ref-based guard does. If you add the guard, please also add a case in useCopiedFlash.test.tsx that flashes, unmounts, then calls the captured flash() again and asserts vi.getTimerCount() is 0 — and confirm that assertion goes red with the guard removed.

中文说明

组件卸载后 flash() 再被调用时,会调度一个重置计时器,而已执行的卸载清理永远不会清掉它,因此 hook 文档中「未决重置会在卸载时被清理」的保证在六个调用点共用的异步入口路径上不成立。所有调用点都在 writeClipboardText(...).then(...) 里调用 flashCopied(),而 clipboard.ts 说明在剪贴板写入权限未决时 writeText 会一直挂起、直到用户回应权限弹窗。用户点击复制后组件在弹窗期间卸载(跳转页面、关闭 Git Log 对话框)时,一次性清理已经执行,promise 随后 settle 并调用 flash(),调度出一个活过环境拆除的孤儿 setTimeout(() => setCopied(false), resetMs)——正是这个 hook 要消灭的泄漏计时器类别。生产环境目前无害(孤儿回调只是 setCopied 空操作),但未来任何在 promise settle 前卸载组件的测试都会重新触发本 PR 要消灭的拆除后失败路径(Linux CI 上 dangerouslyIgnoreUnhandledErrors 为 false)。

建议修复:在 hook 内跟踪挂载状态,flash 开头对已卸载直接返回(见上方代码)。注意各调用点把 flashCopied 列入了自己的 useCallback 依赖(如本 diff 中 MessageTimestamp.tsx}, [copyText, flashCopied]);),修复必须保持 flash 跨渲染的引用稳定——基于 ref 的守卫可以做到。如果加了这个守卫,请在 useCopiedFlash.test.tsx 补一条用例:flash、卸载、再次调用捕获的 flash() 并断言 vi.getTimerCount() 为 0——并确认删掉守卫时该断言变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

return [copied, flash];
}
Loading