From 390a35bf9321c2ed85c3233957bc07451e18d201 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Sun, 1 Mar 2026 09:48:56 -0800 Subject: [PATCH] ui-svelte: add copy button to markdown code blocks (#537) Add a copy-to-clipboard button that appears on hover for each code block rendered in the chat interface assistant messages. - Svelte action `codeBlockCopy` injects a button into every `
`
element
- MutationObserver reattaches buttons as streaming content arrives
- Button shows a check icon for 2 seconds after a successful copy
- Uses clipboard API with execCommand fallback for non-secure contexts
- CSS hides button by default and reveals it on pre:hover

https://claude.ai/code/session_01PTA5ao5YQuFAS6a9juLeZW

---------

Co-authored-by: Claude 
---
 ui-svelte/package-lock.json                   |  7 --
 .../components/playground/ChatMessage.svelte  | 71 ++++++++++++++++++-
 2 files changed, 70 insertions(+), 8 deletions(-)

diff --git a/ui-svelte/package-lock.json b/ui-svelte/package-lock.json
index 1958d64d..8c86b603 100644
--- a/ui-svelte/package-lock.json
+++ b/ui-svelte/package-lock.json
@@ -925,7 +925,6 @@
       "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
         "debug": "^4.4.1",
@@ -1308,7 +1307,6 @@
       "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "undici-types": "~7.16.0"
       }
@@ -1441,7 +1439,6 @@
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "license": "MIT",
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -3452,7 +3449,6 @@
       "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@types/estree": "1.0.8"
       },
@@ -3565,7 +3561,6 @@
       "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.5.tgz",
       "integrity": "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@jridgewell/remapping": "^2.3.4",
         "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3721,7 +3716,6 @@
       "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
       "dev": true,
       "license": "Apache-2.0",
-      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -3900,7 +3894,6 @@
       "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.4",
diff --git a/ui-svelte/src/components/playground/ChatMessage.svelte b/ui-svelte/src/components/playground/ChatMessage.svelte
index 2bbb5fcc..a1eae79a 100644
--- a/ui-svelte/src/components/playground/ChatMessage.svelte
+++ b/ui-svelte/src/components/playground/ChatMessage.svelte
@@ -116,6 +116,47 @@
       cancelEdit();
     }
   }
+
+  const COPY_SVG = ``;
+  const CHECK_SVG = ``;
+
+  function codeBlockCopy(node: HTMLElement) {
+    function attachButtons() {
+      node.querySelectorAll('pre:not([data-copy-btn])').forEach(pre => {
+        pre.setAttribute('data-copy-btn', 'true');
+        const btn = document.createElement('button');
+        btn.className = 'code-copy-btn';
+        btn.title = 'Copy code';
+        btn.innerHTML = COPY_SVG;
+        btn.addEventListener('click', async () => {
+          const text = pre.querySelector('code')?.textContent ?? pre.textContent ?? '';
+          try {
+            if (navigator.clipboard && window.isSecureContext) {
+              await navigator.clipboard.writeText(text);
+            } else {
+              const ta = document.createElement('textarea');
+              ta.value = text;
+              ta.style.cssText = 'position:fixed;left:-9999px';
+              document.body.appendChild(ta);
+              ta.select();
+              document.execCommand('copy');
+              document.body.removeChild(ta);
+            }
+            btn.innerHTML = CHECK_SVG;
+            btn.classList.add('copied');
+            setTimeout(() => { btn.innerHTML = COPY_SVG; btn.classList.remove('copied'); }, 2000);
+          } catch (e) {
+            console.error('copy failed', e);
+          }
+        });
+        pre.appendChild(btn);
+      });
+    }
+    attachButtons();
+    const mo = new MutationObserver(attachButtons);
+    mo.observe(node, { childList: true, subtree: true });
+    return { destroy: () => mo.disconnect() };
+  }
 
 
 
@@ -174,7 +215,7 @@ {#if showRaw}
{textContent}
{:else} -
+
{#each renderedParts.blocks as block (block.id)} {@html block.html} {/each} @@ -299,14 +340,42 @@