feat(web): tap-to-copy button on every chat message

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 01:09:27 +00:00
parent e1e89c07e4
commit cebb87205c
2 changed files with 79 additions and 0 deletions
+63
View File
@@ -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);