diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 35667011..943e91c6 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -845,17 +845,29 @@ const useMorphdomMarkdown = ({ // to the trailing (growing) block instead of the whole message. blocks.forEach((block, index) => { let el = existing[index]; + let isNewBlock = false; if (!el) { el = document.createElement('div'); el.setAttribute('data-md-block', ''); el.style.display = 'contents'; target.appendChild(el); + isNewBlock = true; } if (el.getAttribute('data-md-id') === block.id) return; const temp = document.createElement('div'); temp.innerHTML = block.html; decorateMarkdown(temp, ctx); + if (isNewBlock && streaming && index > 0) { + // A freshly committed block enters with a short reveal. The class + // goes on the block's children — the wrapper is display:contents + // and cannot animate — and the transform never changes layout, so + // row measurement stays exact. Skipped for the first block so a + // full initial render does not shimmer. + for (const child of Array.from(temp.children)) { + child.classList.add('oc-md-block-enter'); + } + } const hadMermaidBlock = shouldRefreshMermaidViewers(el); const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp); morphdom(el, temp, { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 822a3168..5d43f798 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -178,9 +178,11 @@ type MarkdownBlock = { raw: string; src: string; mode: 'full' | 'live'; - // When false, skip syntax highlighting for this block. Set for the actively - // streaming open code fence so we don't re-tokenize a growing block ~40x/sec - // (O(n^2)); it highlights once the fence closes and becomes a stable block. + // When false, skip syntax highlighting for this block. Block-level commit + // feeds the open fence whole lines at the throttle cadence (<=10/sec), so a + // partial fence highlights too and streamed code arrives colored; only a + // very large open fence falls back to plain text until it closes, keeping + // the repeated worker re-tokenization bounded. highlight: boolean; }; @@ -201,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => { return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last); }; +// Above this, re-highlighting the still-open fence on every committed line +// costs more than the colored preview is worth; the block highlights in one +// pass when the fence closes. +const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300; + const heal = (text: string): string => { try { return remend(text, { linkMode: 'text-only' }); @@ -250,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => { const raw = token.raw ?? ''; const isLast = i === tail; const openFence = token.type === 'code' && hasOpenFence(raw); + const openFenceHighlight = openFence + && raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT; blocks.push({ raw, src: openFence ? raw : heal(raw), mode: isLast ? 'live' : 'full', - highlight: !openFence, + highlight: !openFence || openFenceHighlight, }); } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 1ba7e73c..4b631c6b 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1378,6 +1378,29 @@ html:not(.dark) .chat-scroll { } } +/* A block committed mid-stream enters with a short fade-and-rise; the + transform is paint-only, so virtualized row measurement is unaffected. */ +@keyframes oc-md-block-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +.oc-md-block-enter { + animation: oc-md-block-enter 140ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .oc-md-block-enter { + animation: none; + } +} + /* While streaming defers the per-line gutter markup, hold its horizontal footprint (2rem column + 0.75rem gap) so the finished pass only fills in the numbers instead of shifting every code line. */