From 0b122cb28f93eb8b5f1baea3ada2dd46d3a010d9 Mon Sep 17 00:00:00 2001 From: Nige Date: Thu, 12 Mar 2026 18:39:15 +0000 Subject: [PATCH] feat(web-chat): add hover copy button for user/assistant messages (#948) * ci(staging): use default branch instead of hardcoded main * feat(web-chat): add hover copy button for message bubbles * fix(web-chat): address Gemini review for copy state and streaming safety * chore(pr): drop unrelated staging workflow change from #948 --- src/channels/web/static/app.js | 56 ++++++++++++++++++++++++------- src/channels/web/static/style.css | 53 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index f5812030..6128f05f 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -702,16 +702,25 @@ function copyCodeBlock(btn) { }); } +function copyMessage(btn) { + const message = btn.closest('.message'); + if (!message) return; + const text = message.getAttribute('data-copy-text') + || message.getAttribute('data-raw') + || message.textContent + || ''; + navigator.clipboard.writeText(text).then(() => { + btn.textContent = 'Copied'; + setTimeout(() => { btn.textContent = 'Copy'; }, 1200); + }).catch(() => { + btn.textContent = 'Failed'; + setTimeout(() => { btn.textContent = 'Copy'; }, 1200); + }); +} + function addMessage(role, content) { const container = document.getElementById('chat-messages'); - const div = document.createElement('div'); - div.className = 'message ' + role; - if (role === 'user') { - div.textContent = content; - } else { - div.setAttribute('data-raw', content); - div.innerHTML = renderMarkdown(content); - } + const div = createMessageElement(role, content); container.appendChild(div); container.scrollTop = container.scrollHeight; } @@ -723,7 +732,11 @@ function appendToLastAssistant(chunk) { const last = messages[messages.length - 1]; const raw = (last.getAttribute('data-raw') || '') + chunk; last.setAttribute('data-raw', raw); - last.innerHTML = renderMarkdown(raw); + last.setAttribute('data-copy-text', raw); + const content = last.querySelector('.message-content'); + if (content) { + content.innerHTML = renderMarkdown(raw); + } container.scrollTop = container.scrollHeight; } else { addMessage('assistant', chunk); @@ -1310,12 +1323,31 @@ function loadHistory(before) { function createMessageElement(role, content) { const div = document.createElement('div'); div.className = 'message ' + role; - if (role === 'user') { - div.textContent = content; + + if (role === 'assistant' || role === 'user') { + div.classList.add('has-copy'); + div.setAttribute('data-copy-text', content); + const copyBtn = document.createElement('button'); + copyBtn.className = 'message-copy-btn'; + copyBtn.type = 'button'; + copyBtn.setAttribute('aria-label', 'Copy message'); + copyBtn.textContent = 'Copy'; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + copyMessage(copyBtn); + }); + div.appendChild(copyBtn); + } + + const body = document.createElement('div'); + body.className = 'message-content'; + if (role === 'user' || role === 'system') { + body.textContent = content; } else { div.setAttribute('data-raw', content); - div.innerHTML = renderMarkdown(content); + body.innerHTML = renderMarkdown(content); } + div.appendChild(body); return div; } diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index a0985ce3..a7e8d4b1 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -666,6 +666,7 @@ body { font-size: 14px; line-height: 1.5; word-wrap: break-word; + position: relative; } .message.user { @@ -686,6 +687,58 @@ body { line-height: 1.6; } +.message.has-copy { + padding-right: 52px; +} + +.message-content { + min-width: 0; +} + +.message-copy-btn { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-secondary); + border-radius: 8px; + font-size: 11px; + padding: 2px 8px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.message.user:hover .message-copy-btn, +.message.assistant:hover .message-copy-btn, +.message.user:focus-within .message-copy-btn, +.message.assistant:focus-within .message-copy-btn { + opacity: 1; + pointer-events: auto; +} + +.message-copy-btn:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.message-copy-btn:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +@media (hover: none) { + .message.user .message-copy-btn, + .message.assistant .message-copy-btn { + opacity: 1; + pointer-events: auto; + } +} + .message.system { align-self: center; background: var(--bg-tertiary);