fix(chat): copy only the displayed reply from the message copy buttons (#3731)

The AI-message copy buttons copied dataset.raw, which is the full
accumulated model output — still containing the <think time="...">
reasoning block and any tool-call markup that the renderer strips for
display. Pasting therefore leaked the model's thinking, and the first
heading after </think> lost its markdown formatting because it was
glued to the closing tag.

Add chatRenderer.copyMessageText(), which mirrors the display pipeline
(stripToolBlocks then extractThinkingBlocks) and falls back to the raw
text when stripping leaves nothing (thinking-only turns), and route
both copy handlers — the message footer and the slash-reply footer —
through it. The interrupted-turn Continue flow intentionally keeps
reading dataset.raw.

Fixes #3722

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Max Hsu
2026-06-11 00:29:22 +08:00
committed by GitHub
parent a0b0420e6f
commit 8bf8212846
3 changed files with 177 additions and 2 deletions
+16 -1
View File
@@ -862,6 +862,20 @@ export function stripToolBlocks(text) {
return cleaned.trim();
}
/**
* Plain-text payload for the message copy buttons: the reply as the renderer
* displays it — tool blocks and <think> reasoning stripped. dataset.raw keeps
* the full model output (chat.js even embeds the elapsed time into the
* <think> tag for reload persistence), so copying it verbatim leaks the
* thinking block (#3722). Falls back to the raw text when stripping leaves
* nothing (e.g. turns interrupted mid-thinking).
*/
export function copyMessageText(msgElement) {
const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw));
return content || raw;
}
/**
* Build a collapsible sources box (used by both research and web search).
*/
@@ -1372,7 +1386,7 @@ export function createMsgFooter(msgElement) {
{ id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
e.stopPropagation();
const btn = e.currentTarget;
uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '');
uiModule.copyToClipboard(copyMessageText(msgElement));
btn.innerHTML = CHECK_ICON;
setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
}},
@@ -2444,6 +2458,7 @@ const chatRenderer = {
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
copyMessageText,
safeToolScreenshotSrc,
safeDisplayImageSrc,
buildSourcesBox,
+1 -1
View File
@@ -380,7 +380,7 @@ function _slashFooter(msgEl) {
copyBtn.innerHTML = _copySvg;
copyBtn.onclick = (e) => {
e.stopPropagation();
uiModule.copyToClipboard(msgEl.dataset.raw || msgEl.querySelector('.body')?.textContent || '');
uiModule.copyToClipboard(chatRenderer.copyMessageText(msgEl));
copyBtn.innerHTML = _checkSvg;
setTimeout(() => { copyBtn.innerHTML = _copySvg; }, 1500);
};