Skip to content

Commit 4845dda

Browse files
committed
Add whole-message copy button to assistant replies
Fenced code blocks and inline code were already copyable, but copying a full reply meant manual selection. Pin a hover-revealed copy button to the top-right of each assistant bubble (live, streamed, and history paths). It copies the raw markdown source and sits on the row rather than inside .msg, which streaming re-renders would destroy.
1 parent f846c3f commit 4845dda

1 file changed

Lines changed: 32 additions & 2 deletions

File tree

packages/core/src/chat/ui/webview.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,19 @@ export function chatHtml(): string {
388388
.preCopy:hover { opacity: 1; color: var(--an-green); border-color: var(--an-green-line); }
389389
.preCopy svg { width: 13px; height: 13px; }
390390
.preCopy.done { color: var(--an-green); opacity: 1; }
391+
/* whole-message copy: pinned top-right of an assistant reply, shown on that row's
392+
hover. Copies the raw markdown source, so pasting elsewhere keeps the formatting.
393+
pointer-events gates the hidden state so the invisible button never eats clicks
394+
or text selection at the bubble's corner. */
395+
.msgCopy { position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; padding: 0;
396+
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
397+
background: var(--an-bg-2); border: 1px solid var(--an-line-soft); border-radius: 6px;
398+
color: var(--vscode-foreground); opacity: 0; pointer-events: none;
399+
transition: opacity 0.12s, color 0.12s; z-index: 2; }
400+
.node.assistant:hover > .msgCopy { opacity: 0.75; pointer-events: auto; }
401+
.node.assistant > .msgCopy:hover { opacity: 1; color: var(--an-green); border-color: var(--an-green-line); }
402+
.node.assistant > .msgCopy.done { opacity: 1; pointer-events: auto; color: var(--an-green); }
403+
.msgCopy svg { width: 13px; height: 13px; }
391404
/* inline code IS its own copy button: a floating control would displace or cover the
392405
words around it, so the span tints on hover and flashes green once the copy lands. */
393406
.msg code.inlineCopy { cursor: pointer; transition: color 0.12s, border-color 0.12s, background 0.12s; }
@@ -2686,6 +2699,22 @@ export function chatHtml(): string {
26862699
}
26872700

26882701
// The footer under an assistant reply: elapsed time + model name (when known).
2702+
// Whole-message copy button on an assistant reply, mirroring the per-code-block
2703+
// affordance. Lives on the ROW (not inside .msg) because streaming re-renders swap
2704+
// .msg's innerHTML — a button inside it would be destroyed on every repaint.
2705+
function addMsgCopy(row, el) {
2706+
if (!row || row.querySelector(':scope > .msgCopy')) return;
2707+
const btn = document.createElement('button');
2708+
btn.className = 'msgCopy'; btn.title = 'Copy message'; btn.innerHTML = COPY_ICON;
2709+
btn.addEventListener('click', (e) => {
2710+
e.stopPropagation();
2711+
copyText(el.dataset.md || el.textContent || '', () => {
2712+
btn.classList.add('done'); btn.innerHTML = CHECK_ICON;
2713+
setTimeout(() => { btn.classList.remove('done'); btn.innerHTML = COPY_ICON; }, 1200);
2714+
});
2715+
});
2716+
row.appendChild(btn);
2717+
}
26892718
function addFooter(row, durationMs, model) {
26902719
if (durationMs == null && !model) return;
26912720
const f = document.createElement('div'); f.className = 'footer';
@@ -3159,11 +3188,12 @@ export function chatHtml(): string {
31593188
streaming.classList.remove('cursor');
31603189
cancelStreamRender(); // drop any pending live render (rAF or trailing timer)
31613190
asMd(streaming, raw);
3191+
if (msg.role === 'assistant') addMsgCopy(streaming._row, streaming);
31623192
streaming = null;
31633193
} else {
31643194
const el = bubble(msg.role, false, badge);
31653195
asMd(el, msg.text);
3166-
if (msg.role === 'assistant') addFooter(el._row, msg.durationMs, msg.model); // time + model
3196+
if (msg.role === 'assistant') { addFooter(el._row, msg.durationMs, msg.model); addMsgCopy(el._row, el); } // time + model + copy
31673197
}
31683198
}
31693199
if (typingEl) tailBody().appendChild(typingEl); // keep the indicator at the thread's tail
@@ -5273,7 +5303,7 @@ export function chatHtml(): string {
52735303
const el = document.createElement('div'); el.className = 'msg ' + m.role;
52745304
if (m.role === 'assistant') renderMd(el, m.text); else { el.textContent = m.text; el.dataset.md = m.text; }
52755305
n.appendChild(el);
5276-
if (m.role === 'assistant') addFooter(n, m.durationMs, m.model);
5306+
if (m.role === 'assistant') { addFooter(n, m.durationMs, m.model); addMsgCopy(n, el); }
52775307
}
52785308
// Insert above the current content, then restore the viewport SYNCHRONOUSLY (before the
52795309
// browser paints) so the user's position never visibly jumps. Disable smooth-scroll for

0 commit comments

Comments
 (0)