From cebb87205c923e6d07927f926369d3ba1f579caf Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 20 Jun 2026 01:09:27 +0000 Subject: [PATCH] feat(web): tap-to-copy button on every chat message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each user and assistant message gets a copy button (⧉ → ✓) that puts the whole message on the clipboard. Assistant copies the raw markdown (its dataset.raw); user copies its text. - copyToClipboard uses the async Clipboard API when available and falls back to a hidden-textarea + execCommand with an explicit iOS selection range, so it works on the iPhone PWA over plain-HTTP LAN (no secure context). - Copy sits in the assistant rate-bar and in a right-aligned bar on user bubbles; tools stay visible on touch (@media hover:none) since there's no hover on iOS. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/web/static/index.html | 63 ++++++++++++++++++++++++++++++++++++++ lyra/web/static/style.css | 16 ++++++++++ 2 files changed, 79 insertions(+) diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index 40ab89e..6fdab29 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -473,6 +473,7 @@ up.addEventListener("click", () => rateMessage(div, 1, up, down)); down.addEventListener("click", () => rateMessage(div, -1, up, down)); bar.appendChild(up); bar.appendChild(down); + bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || "")); div.appendChild(bar); } @@ -491,6 +492,62 @@ down.classList.toggle("rated", value === -1); } + // Copy text to the clipboard. Uses the async Clipboard API when available + // (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for + // iOS over plain-HTTP LAN (where navigator.clipboard is undefined). + function copyToClipboard(text) { + text = text == null ? "" : String(text); + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).catch(() => legacyCopy(text)); + } + return legacyCopy(text); + } + function legacyCopy(text) { + return new Promise((resolve, reject) => { + const ta = document.createElement("textarea"); + ta.value = text; + ta.contentEditable = "true"; + ta.readOnly = false; + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + const range = document.createRange(); + range.selectNodeContents(ta); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + ta.setSelectionRange(0, text.length); // iOS needs an explicit range + let ok = false; + try { ok = document.execCommand("copy"); } catch (e) { ok = false; } + sel.removeAllRanges(); + document.body.removeChild(ta); + ok ? resolve() : reject(new Error("copy failed")); + }); + } + // A small per-message copy button. getText is read at click time. + function makeCopyBtn(getText) { + const b = document.createElement("button"); + b.className = "copy-btn"; + b.type = "button"; + b.textContent = "⧉"; + b.title = "Copy message"; + b.addEventListener("click", (e) => { + e.stopPropagation(); + copyToClipboard(typeof getText === "function" ? getText() : getText) + .then(() => { + b.textContent = "✓"; b.classList.add("copied"); + setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200); + }) + .catch(() => { + b.textContent = "✗"; + setTimeout(() => { b.textContent = "⧉"; }, 1200); + }); + }); + return b; + } + function addMessage(role, text, autoScroll = true) { const messagesEl = document.getElementById("messages"); @@ -502,6 +559,12 @@ addRateBar(msgDiv); } else { msgDiv.textContent = text; + if (role === "user") { + const bar = document.createElement("div"); + bar.className = "rate-bar"; + bar.appendChild(makeCopyBtn(() => text)); + msgDiv.appendChild(bar); + } } messagesEl.appendChild(msgDiv); diff --git a/lyra/web/static/style.css b/lyra/web/static/style.css index d8b7329..7e014d3 100644 --- a/lyra/web/static/style.css +++ b/lyra/web/static/style.css @@ -1182,6 +1182,22 @@ select:hover { .rate-btn:hover { filter: none; background: var(--accent-soft); } .rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; } +/* Per-message copy button (lives in the rate-bar for assistant, its own bar for user). */ +.copy-btn { + background: none; border: none; cursor: pointer; font-size: 0.85rem; + padding: 2px 6px; border-radius: 5px; line-height: 1; color: var(--text-fade); + -webkit-tap-highlight-color: transparent; +} +.copy-btn:hover { background: var(--accent-soft); color: var(--accent); } +.copy-btn.copied { color: var(--good); } +/* User bubbles are right-aligned, so right-align their copy bar too. */ +.msg.user .rate-bar { justify-content: flex-end; opacity: 0.4; } +.msg.user:hover .rate-bar { opacity: 0.85; } +/* Touch devices have no hover — keep the tools tappable/visible. */ +@media (hover: none) { + .rate-bar, .msg.user .rate-bar { opacity: 0.65; } +} + /* Quality floor: honor reduced-motion preference. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after {