From 4246cf2431c4b6ad94da76bdd730601b97b6ecba Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 14 Apr 2026 10:01:01 -0400 Subject: [PATCH] fix(run-r): add per-block copy buttons and restore IDE action buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the new React shinychat, btw-run-r-result is rendered as a raw HTML island (via ) and never passes through React's markdown pipeline. As a result, two features that previously worked were broken: 1. Per-block copy buttons: shinychat's CopyableCodeBlock React component is never reached for code blocks inside HTML islands, so no copy buttons were added to source/output/message/warning/error blocks. 2. IDE action buttons (insert at cursor, insert in new file, run in console): btw_app.js enhanced code blocks by observing shiny-markdown-stream (the old Lit element) and finding .code-copy-button nodes. Both the element name and class are different in the new React shinychat. Changes: btw-run-r.js — add addBlockCopyButtons() method Called at the end of render(). Adds a .btw-block-copy-btn button to each
 inside .btw-run-output, copying that block's text content.
  Idempotent (guards against duplicate buttons on re-render). After
  adding buttons, dispatches a "btw-run-r-rendered" custom event so
  btw_app.js can install IDE action buttons without btw-run-r.js needing
  to import from the app layer.

btw_app.js — update IDE enhancement for new React shinychat
  - STREAM_SELECTOR now matches both "shiny-markdown-stream" (old Lit)
    and ".shiny-chat-message-content" (new React). All three querySelectorAll
    and el.matches() calls use this constant.
  - attachObserver now calls callback(el) immediately on attach. In React,
    .shiny-chat-message-content is inserted with its full subtree in one
    commit, so the per-element MutationObserver never fires for that
    initial content; the immediate callback handles it.
  - Add enhanceBtwCodeActions(result): handles .btw-block-copy-btn buttons
    inside btw-run-r-result, adding IDE action buttons to .btw-output-source
    blocks only. Wired up via the "btw-run-r-rendered" event.
  - Button order: IDE action buttons are appended before the copy button
    in both enhanceCodeActions and enhanceBtwCodeActions, so copy is
    always rightmost.

btw-run-r.css / btw_app.css — styles for .btw-block-copy-btn
  The button uses  markup and a CSS mask-image icon (same
  vscode codicon as .code-copy-button) so no inline SVG is needed.
  btw-run-r.css carries the self-contained icon rules so the button
  renders correctly in any shinychat version. btw_app.css adds a reset
  for when the button is moved inside .code-action-wrapper (static
  position, full opacity, auto sizing).
  btw-run-r.css hides .code-copy-button inside btw output blocks to
  prevent the old shinychat pipeline from adding a duplicate button.
---
 inst/js/app/btw_app.css     | 36 ++++++++++++++-----------
 inst/js/app/btw_app.js      | 37 +++++++++++++++++++++++---
 inst/js/run-r/btw-run-r.css | 53 +++++++++++++++++++++++++++++++++++--
 inst/js/run-r/btw-run-r.js  | 39 +++++++++++++++++++++++++++
 4 files changed, 143 insertions(+), 22 deletions(-)

diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css
index 538bb6c9..beb5f44c 100644
--- a/inst/js/app/btw_app.css
+++ b/inst/js/app/btw_app.css
@@ -144,42 +144,46 @@ pre:has(.code-action-wrapper) {
   cursor: pointer;
 }
 
-.code-action-wrapper .code-copy-button {
+.code-action-wrapper .code-copy-button,
+.code-action-wrapper .btw-block-copy-btn {
   position: static;
   top: auto;
   right: auto;
   margin-top: 0;
   margin-right: 0;
+  opacity: 1;
+  width: auto;
+  height: auto;
+}
+
+.code-action-wrapper > button > .bi::after {
+  content: "";
+  display: block;
+  width: 1rem;
+  height: 1rem;
+  background-color: var(--bs-body-color, #222);
 }
 
 /* @vscode/codicons: copy, insert-at-cursor, new-file, terminal (MIT) */
-.code-copy-button > .bi::after {
+.code-copy-button > .bi::after,
+.btw-block-copy-btn > .bi::after {
   mask-image: url('data:image/svg+xml,');
 }
 
+.btw-block-copy-btn-checked > .bi::after {
+  mask-image: url('data:image/svg+xml,');
+  background-color: var(--bs-success, #198754);
+}
+
 .insert-at-cursor-button > .bi::after {
-  content: "";
-  display: block;
-  height: 1rem;
-  width: 1rem;
-  background-color: var(--bs-body-color, #222);
   mask-image: url('data:image/svg+xml,');
 }
 
 .insert-new-file-button > .bi::after {
-  content: "";
-  display: block;
-  height: 1rem;
-  width: 1rem;
   background-color: var(--bs-body-color, #222);
   mask-image: url('data:image/svg+xml,');
 }
 
 .insert-console-button > .bi::after {
-  content: "";
-  display: block;
-  height: 1rem;
-  width: 1rem;
-  background-color: var(--bs-body-color, #222);
   mask-image: url('data:image/svg+xml,');
 }
diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js
index fc06c84a..042f42a1 100644
--- a/inst/js/app/btw_app.js
+++ b/inst/js/app/btw_app.js
@@ -85,12 +85,21 @@ const inIframe = window.self !== window.top
 const inIDE = document.querySelector(".btw-in-ide") !== null
 
 if (inIframe && inIDE) {
+  // Matches both old Lit (shiny-markdown-stream) and new React (.shiny-chat-message-content)
+  const STREAM_SELECTOR = "shiny-markdown-stream, .shiny-chat-message-content"
+
   const stopObserving = observeShinyMarkdownStream((streamEl) => {
     enhanceCodeActions(streamEl)
   })
 
+  // New React shinychat: btw-run-r-result dispatches this event after rendering
+  // its per-block copy buttons. Add IDE action buttons to source code blocks only.
+  document.addEventListener("btw-run-r-rendered", (e) => {
+    enhanceBtwCodeActions(e.target)
+  })
+
   function isMarkdownStream(el) {
-    return el.matches("shiny-markdown-stream")
+    return el.matches(STREAM_SELECTOR)
   }
 
   function canEnhance(el) {
@@ -107,6 +116,11 @@ if (inIframe && inIDE) {
     function attachObserver(el) {
       if (!isMarkdownStream(el) || observers.has(el)) return
 
+      // Enhance any content already in the element. In React shinychat the
+      // element arrives fully populated in one commit, so the per-element
+      // observer below would never fire for that initial content.
+      callback(el)
+
       let timeoutId = null
 
       const observer = new MutationObserver(() => {
@@ -123,7 +137,7 @@ if (inIframe && inIDE) {
       observers.set(el, observer)
     }
 
-    document.querySelectorAll("shiny-markdown-stream").forEach(attachObserver)
+    document.querySelectorAll(STREAM_SELECTOR).forEach(attachObserver)
 
     const rootObserver = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
@@ -135,7 +149,7 @@ if (inIframe && inIDE) {
           }
 
           node
-            .querySelectorAll?.("shiny-markdown-stream")
+            .querySelectorAll?.(STREAM_SELECTOR)
             .forEach(attachObserver)
         })
       })
@@ -159,8 +173,23 @@ if (inIframe && inIDE) {
       if (!pre || !canEnhance(pre)) return
 
       const wrapper = ensureWrapper(pre)
-      moveCopyButton(wrapper, copyButton)
       installActionButtons(wrapper, pre)
+      moveCopyButton(wrapper, copyButton)
+    })
+  }
+
+  function enhanceBtwCodeActions(result) {
+    result.querySelectorAll(".btw-block-copy-btn").forEach((copyButton) => {
+      const pre = copyButton.closest("pre")
+      if (!pre) return
+
+      const wrapper = ensureWrapper(pre)
+
+      // Install IDE action buttons first, then copy button so it appears last
+      if (pre.closest(".btw-output-source")) {
+        installActionButtons(wrapper, pre)
+      }
+      moveCopyButton(wrapper, copyButton)
     })
   }
 
diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css
index 02a6a90f..3bb2f221 100644
--- a/inst/js/run-r/btw-run-r.css
+++ b/inst/js/run-r/btw-run-r.css
@@ -29,6 +29,7 @@ btw-run-r-result .card-body {
   border: none;
   border-radius: 0;
   overflow-x: auto;
+  position: relative;
 }
 
 .btw-run-output pre:not(.btw-output-source) {
@@ -41,11 +42,59 @@ btw-run-r-result .card-body {
   background-color: var(--bs-black);
 }
 
-.btw-run-output > :not(.btw-output-source) .code-copy-button {
-  /* TODO: Figure out how to disable markdown-stream code copy button */
+.btw-run-output .code-copy-button {
+  /* Hide shinychat's pipeline copy button — btw adds its own via addBlockCopyButtons() */
   display: none;
 }
 
+/* Per-block copy buttons */
+.btw-block-copy-btn {
+  position: absolute;
+  top: 0.25rem;
+  right: 0.25rem;
+  width: 1.5rem;
+  height: 1.5rem;
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+  color: var(--bs-body-color);
+  opacity: 0;
+  transition: opacity 0.2s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 0.25rem;
+}
+
+.btw-run-output pre:hover .btw-block-copy-btn {
+  opacity: 0.6;
+}
+
+.btw-block-copy-btn:hover {
+  opacity: 1 !important;
+}
+
+.btw-block-copy-btn:focus {
+  outline: 2px solid var(--bs-primary);
+  outline-offset: 2px;
+  opacity: 1;
+}
+
+.btw-block-copy-btn > .bi::after {
+  content: "";
+  display: block;
+  width: 1rem;
+  height: 1rem;
+  background-color: var(--bs-body-color, #222);
+  mask-image: url('data:image/svg+xml,');
+}
+
+.btw-block-copy-btn-checked > .bi::after {
+  mask-image: url('data:image/svg+xml,');
+  background-color: var(--bs-success, #198754);
+}
+
 .btw-run-output pre code {
   font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
   white-space: pre;
diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js
index 8a6c17ba..871f01e0 100644
--- a/inst/js/run-r/btw-run-r.js
+++ b/inst/js/run-r/btw-run-r.js
@@ -332,6 +332,8 @@ class BtwRunRResult extends HTMLElement {
       }
     }
 
+    this.addBlockCopyButtons()
+
     // Allow clicking anywhere on the header to toggle, except on action buttons
     const header = this.querySelector(".card-header")
     if (header) {
@@ -348,6 +350,43 @@ class BtwRunRResult extends HTMLElement {
     }
   }
 
+  /**
+   * Add per-block copy buttons to each pre element in the output container.
+   * Idempotent: skips any pre that already has a button (safe to call after re-render).
+   */
+  addBlockCopyButtons() {
+    const outputContainer = this.querySelector(".btw-run-output")
+    if (!outputContainer) return
+
+    outputContainer.querySelectorAll("pre").forEach((pre) => {
+      if (pre.querySelector(".btw-block-copy-btn")) return
+
+      const btn = document.createElement("button")
+      btn.className = "btw-block-copy-btn"
+      btn.setAttribute("aria-label", "Copy to clipboard")
+      btn.innerHTML = ''
+
+      btn.addEventListener("click", async (e) => {
+        e.stopPropagation()
+        const code = pre.querySelector("code")
+        const text = code?.textContent ?? pre.textContent ?? ""
+        try {
+          await copyToClipboard(text)
+          btn.classList.add("btw-block-copy-btn-checked")
+          setTimeout(() => {
+            btn.classList.remove("btw-block-copy-btn-checked")
+          }, 1500)
+        } catch (err) {
+          console.error("Failed to copy:", err)
+        }
+      })
+
+      pre.appendChild(btn)
+    })
+
+    this.dispatchEvent(new CustomEvent("btw-run-r-rendered", { bubbles: true }))
+  }
+
   /**
    * Escape a string for use in an HTML attribute
    * @param {string} str