feat(ui): per-message code blocks get a language header + copy button

Chat code blocks now render inside a framed block with a header showing the
language and a copy button (delegated handler, copies the block and flips to a
check briefly). Decoration + highlighting run from a MutationObserver scoped to
the messages container, which fires reliably for streamed responses AND for
chats loaded/switched from storage - the prior render-keyed effect missed the
load path (code was left unhighlighted on reload). The observer disconnects
while mutating so it does not retrigger on its own edits.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-18 17:00:38 +00:00
parent a5a4701f05
commit eb8b9b125d
3 changed files with 112 additions and 5 deletions

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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) <pre> 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 = '<i class="fas fa-copy" aria-hidden="true"></i>'
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 = '<i class="fas fa-check" aria-hidden="true"></i>'
btn.classList.add('code-copy-btn--ok')
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-copy" aria-hidden="true"></i>'
btn.classList.remove('code-copy-btn--ok')
}, 2000)
})
}