Skip to content

Commit 63f8304

Browse files
fix(web): ignore obsolete clipboard feedback
1 parent 12b486a commit 63f8304

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

‎apps/web/src/components/ChatMarkdown.test.tsx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,53 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
7373
return button.props as ComponentProps<typeof Button>;
7474
}
7575

76+
describe("Markdown copy attempt order", () => {
77+
it("ignores an older write that completes after the newest feedback expires", async () => {
78+
let finishFirst!: () => void;
79+
const writeText = vi
80+
.fn()
81+
.mockImplementationOnce(
82+
() =>
83+
new Promise<void>((resolve) => {
84+
finishFirst = resolve;
85+
}),
86+
)
87+
.mockResolvedValue(undefined);
88+
vi.stubGlobal("navigator", { clipboard: { writeText } });
89+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
90+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
91+
let renderer: ReactTestRenderer | undefined;
92+
try {
93+
await act(async () => {
94+
renderer = create(<ChatMarkdown cwd="/tmp/project" text={"```text\nhello\n```"} />);
95+
});
96+
const copy = () =>
97+
codeButton(renderer!, "Copy code").onClick?.(
98+
{} as Parameters<NonNullable<ReturnType<typeof codeButton>["onClick"]>>[0],
99+
);
100+
await act(async () => {
101+
copy();
102+
});
103+
await act(async () => {
104+
copy();
105+
});
106+
expect(codeButton(renderer!, "Copied")).toBeDefined();
107+
await act(async () => {
108+
vi.advanceTimersByTime(1200);
109+
});
110+
await act(async () => {
111+
finishFirst();
112+
});
113+
expect(codeButton(renderer!, "Copy code")).toBeDefined();
114+
} finally {
115+
await act(async () => renderer?.unmount());
116+
vi.useRealTimers();
117+
vi.unstubAllGlobals();
118+
vi.restoreAllMocks();
119+
}
120+
});
121+
});
122+
76123
describe("ChatMarkdown context references", () => {
77124
it("renders text and image references through the chip renderer, with readable fallback", async () => {
78125
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);

‎apps/web/src/components/ChatMarkdown.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
703703
const [expanded, setExpanded] = useState(readInitialWordWrapSetting);
704704
const [copied, setCopied] = useState(false);
705705
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
706+
const copyAttemptRef = useRef(0);
706707
const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";
707708
const copyLabel = copied ? "Copied" : "Copy table";
708709

@@ -739,13 +740,15 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
739740
format === "markdown"
740741
? serializeTableElementToMarkdown(table)
741742
: serializeTableElementToCsv(table);
743+
const attempt = ++copyAttemptRef.current;
742744
// A retry must not keep showing the previous attempt's Copied state.
743745
if (copiedTimerRef.current != null) {
744746
clearTimeout(copiedTimerRef.current);
745747
copiedTimerRef.current = null;
746748
}
747749
setCopied(false);
748750
const reportFailure = (cause: unknown) => {
751+
if (attempt !== copyAttemptRef.current) return;
749752
reportMarkdownActionFailure({ operation: "copy-table", format }, cause);
750753
toastManager.add(
751754
stackedThreadToast({
@@ -756,6 +759,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
756759
);
757760
};
758761
void writeTextToClipboard(text, "table").then((didCopy) => {
762+
if (attempt !== copyAttemptRef.current) return;
759763
if (!didCopy) {
760764
reportFailure(new Error("Clipboard write produced no result."));
761765
return;
@@ -765,6 +769,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
765769
}
766770
setCopied(true);
767771
copiedTimerRef.current = setTimeout(() => {
772+
if (attempt !== copyAttemptRef.current) return;
768773
setCopied(false);
769774
copiedTimerRef.current = null;
770775
}, 1200);
@@ -773,6 +778,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
773778

774779
useEffect(
775780
() => () => {
781+
copyAttemptRef.current += 1;
776782
if (copiedTimerRef.current != null) {
777783
clearTimeout(copiedTimerRef.current);
778784
copiedTimerRef.current = null;
@@ -945,17 +951,20 @@ function MarkdownCodeBlock({
945951
const [copied, setCopied] = useState(false);
946952
const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
947953
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
954+
const copyAttemptRef = useRef(0);
948955
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
949956
const copyLabel = copied ? "Copied" : "Copy code";
950957

951958
const handleCopy = useCallback(() => {
959+
const attempt = ++copyAttemptRef.current;
952960
// A retry must not keep showing the previous attempt's Copied state.
953961
if (copiedTimerRef.current != null) {
954962
clearTimeout(copiedTimerRef.current);
955963
copiedTimerRef.current = null;
956964
}
957965
setCopied(false);
958966
const reportFailure = (cause: unknown) => {
967+
if (attempt !== copyAttemptRef.current) return;
959968
reportMarkdownActionFailure(
960969
{
961970
operation: "copy-code-block",
@@ -973,6 +982,7 @@ function MarkdownCodeBlock({
973982
);
974983
};
975984
void writeTextToClipboard(code, "code").then((didCopy) => {
985+
if (attempt !== copyAttemptRef.current) return;
976986
if (!didCopy) {
977987
reportFailure(new Error("Clipboard write produced no result."));
978988
return;
@@ -982,6 +992,7 @@ function MarkdownCodeBlock({
982992
}
983993
setCopied(true);
984994
copiedTimerRef.current = setTimeout(() => {
995+
if (attempt !== copyAttemptRef.current) return;
985996
setCopied(false);
986997
copiedTimerRef.current = null;
987998
}, 1200);
@@ -990,6 +1001,7 @@ function MarkdownCodeBlock({
9901001

9911002
useEffect(
9921003
() => () => {
1004+
copyAttemptRef.current += 1;
9931005
if (copiedTimerRef.current != null) {
9941006
clearTimeout(copiedTimerRef.current);
9951007
copiedTimerRef.current = null;

‎apps/web/src/components/preview/PreviewView.tsx‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ export function PreviewView({
347347
(artifact) => {
348348
if (!artifact) return;
349349
let pathCopied = false;
350+
let copyAttempt = 0;
350351
let toastId: ReturnType<typeof toastManager.add>;
351352

352353
const reportCopyFailure = (error: unknown) => {
@@ -361,22 +362,31 @@ export function PreviewView({
361362
);
362363
};
363364
const copyPath = () => {
365+
const attempt = ++copyAttempt;
364366
if (pathCopied) {
365367
pathCopied = false;
366368
updateRecordingToast();
367369
}
368-
void writeTextToClipboard(artifact.path, "recording path").then((didCopy) => {
369-
if (!didCopy) {
370-
reportCopyFailure(new Error("Clipboard write produced no result."));
371-
return;
372-
}
373-
pathCopied = true;
374-
updateRecordingToast();
375-
window.setTimeout(() => {
376-
pathCopied = false;
370+
void writeTextToClipboard(artifact.path, "recording path").then(
371+
(didCopy) => {
372+
if (attempt !== copyAttempt || !isMountedRef.current) return;
373+
if (!didCopy) {
374+
reportCopyFailure(new Error("Clipboard write produced no result."));
375+
return;
376+
}
377+
pathCopied = true;
377378
updateRecordingToast();
378-
}, 2_000);
379-
}, reportCopyFailure);
379+
window.setTimeout(() => {
380+
if (attempt !== copyAttempt || !isMountedRef.current) return;
381+
pathCopied = false;
382+
updateRecordingToast();
383+
}, 2_000);
384+
},
385+
(error) => {
386+
if (attempt !== copyAttempt || !isMountedRef.current) return;
387+
reportCopyFailure(error);
388+
},
389+
);
380390
};
381391

382392
const revealAction = {

0 commit comments

Comments
 (0)