diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index c0db81666..b6cd4917a 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -3185,6 +3185,49 @@ select.input { font-size: 0.8125rem; } +/* Code block with a header (language + copy), injected by enhanceCodeBlocks. */ +.code-block { + margin: var(--spacing-sm) 0; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-bg-primary); +} +.code-block__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px var(--spacing-sm); + background: var(--color-surface-sunken); + border-bottom: 1px solid var(--color-border-subtle); +} +.code-block__lang { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); +} +.code-block .code-copy-btn { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 4px 6px; + border-radius: var(--radius-sm); + font-size: var(--text-sm); + transition: color var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default); +} +.code-block .code-copy-btn:hover { color: var(--color-text-primary); background: var(--color-bg-hover); } +.code-block .code-copy-btn--ok { color: var(--color-success); } +/* Inner pre sits flush inside the framed block. */ +.code-block pre { + margin: 0 !important; + border: none !important; + border-radius: 0 !important; +} + .chat-message-user .chat-message-content pre { background: var(--color-surface-sunken); border-color: var(--color-border-subtle); diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 0cfed4d47..cddf8e75d 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { fromState } from '../utils/editorNav' import { useChat } from '../hooks/useChat' import ModelSelector from '../components/ModelSelector' -import { renderMarkdown, highlightAll } from '../utils/markdown' +import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown' import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts' import CanvasPanel from '../components/CanvasPanel' import Toggle from '../components/Toggle' @@ -658,12 +658,25 @@ export default function Chat() { return () => window.removeEventListener('keydown', onKey) }, [focusActive]) - // Highlight code blocks + // Highlight code blocks + add per-block copy buttons. A MutationObserver on + // the messages container is more reliable than render-keyed effects: it fires + // for loaded/switched chats AND for streaming token updates, regardless of + // render timing. The observer is disconnected while we mutate so our own + // highlight/enhance edits don't retrigger it. useEffect(() => { - if (messagesRef.current) { - highlightAll(messagesRef.current) + const el = messagesRef.current + if (!el) return + let obs + const run = () => { + obs?.disconnect() + highlightAll(el) + enhanceCodeBlocks(el) + obs?.observe(el, { childList: true, subtree: true }) } - }, [activeChat?.history, streamingContent]) + obs = new MutationObserver(run) + run() + return () => obs.disconnect() + }, [activeChat?.id]) // Auto-grow textarea const autoGrowTextarea = useCallback(() => { diff --git a/core/http/react-ui/src/utils/markdown.js b/core/http/react-ui/src/utils/markdown.js index 3ada31815..24f253e2b 100644 --- a/core/http/react-ui/src/utils/markdown.js +++ b/core/http/react-ui/src/utils/markdown.js @@ -1,6 +1,7 @@ import { marked } from 'marked' import DOMPurify from 'dompurify' import hljs from './hljs' +import { copyToClipboard } from './clipboard' marked.setOptions({ highlight(code, lang) { @@ -25,3 +26,53 @@ export function highlightAll(element) { hljs.highlightElement(block) }) } + +// Decorate each (not-yet-enhanced)
code block in `element` with a header
+// bar carrying the language label and a copy button. Idempotent: re-running on
+// the same DOM (e.g. while streaming) only touches new blocks. Copy clicks are
+// handled by a single delegated document listener (registered below).
+export function enhanceCodeBlocks(element) {
+ if (!element) return
+ element.querySelectorAll('pre:not([data-enhanced])').forEach((pre) => {
+ pre.setAttribute('data-enhanced', '1')
+ const code = pre.querySelector('code')
+ const langMatch = code && code.className.match(/language-(\w+)/)
+ const lang = langMatch ? langMatch[1] : 'text'
+ const wrap = document.createElement('div')
+ wrap.className = 'code-block'
+ const head = document.createElement('div')
+ head.className = 'code-block__head'
+ const label = document.createElement('span')
+ label.className = 'code-block__lang'
+ label.textContent = lang
+ const btn = document.createElement('button')
+ btn.type = 'button'
+ btn.className = 'code-copy-btn'
+ btn.setAttribute('aria-label', 'Copy code')
+ btn.innerHTML = ''
+ head.appendChild(label)
+ head.appendChild(btn)
+ pre.parentNode.insertBefore(wrap, pre)
+ wrap.appendChild(head)
+ wrap.appendChild(pre)
+ })
+}
+
+// One delegated handler for every code-copy button, anywhere in the app.
+if (typeof document !== 'undefined' && !window.__codeCopyDelegate) {
+ window.__codeCopyDelegate = true
+ document.addEventListener('click', async (e) => {
+ const btn = e.target.closest?.('.code-copy-btn')
+ if (!btn) return
+ const code = btn.closest('.code-block')?.querySelector('pre code')
+ if (!code) return
+ const ok = await copyToClipboard(code.innerText)
+ if (!ok) return
+ btn.innerHTML = ''
+ btn.classList.add('code-copy-btn--ok')
+ setTimeout(() => {
+ btn.innerHTML = ''
+ btn.classList.remove('code-copy-btn--ok')
+ }, 2000)
+ })
+}