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