From d71bcb5025bde76287c786720c579e196dd70438 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 18:40:42 +0300 Subject: [PATCH] feat(chat): colored streaming code and a reveal for committed blocks With block-level commit the open code fence grows by whole lines at the throttle cadence, so the old reason to skip highlighting it (re-tokenizing a growing block ~40x/sec) no longer applies: a partial fence now highlights too, and streamed code arrives colored instead of colorizing only when the fence closes. Fences beyond 300 lines fall back to plain text until closed, keeping the repeated worker re-tokenization bounded. A freshly committed block also enters with a short fade-and-rise. The class goes on the block's children (the wrapper is display:contents and cannot animate) and the transform is paint-only, so virtualized row measurement is unaffected; reduced motion disables it. Verified over CDP on a production build: streamed code inside a still- open fence renders highlighted, and the code text's x-position is identical during and after the stream. --- .../components/chat/MarkdownRendererImpl.tsx | 12 ++++++++++ .../components/chat/markdown/markdownCore.ts | 17 ++++++++++---- packages/ui/src/index.css | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) 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. */