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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 18:40:42 +03:00
parent 3b8ed8fc36
commit d71bcb5025
3 changed files with 48 additions and 4 deletions
@@ -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, {
@@ -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,
});
}
+23
View File
@@ -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. */