1// streamingRenderer.js 2// 3// The DOM shell for incremental streaming markdown rendering. One instance owns 4// the DOM of one streaming assistant message and is the only thing that writes to 5// it while it streams. 6// 7// It keeps the message as two regions, separated by an invisible comment marker so 8// the rendered blocks are direct children of the container (no wrapper elements to 9// disturb CSS): 10// 11// [ finalized block, frozen ][ finalized block, frozen ] <!--tail--> [ live tail ] 12// 13// - Finalized blocks are rendered once and never touched again â so code-block 14// hover buttons can't flicker and code is highlighted exactly once. 15// - The live tail (the still-growing trailing block) is re-rendered each token, 16// except an open code fence, which streams in append-mode (text appended to a 17// stable <pre>, highlighted once when it closes). 18// 19// All the "is this safe to freeze?" logic lives in the pure segmenter; this file 20// is deliberately mechanical. If anything throws, it latches into a full-re-render 21// fallback so a bug can never produce broken output â only today's behavior. 22 23import { splitFinalized, describeOpenFence } from './streamingSegmenter.js'; 24 25// Compile-time escape hatch: set to false to force the plain full-re-render path. 26// (The per-instance try/catch `degraded` fallback below is the runtime safety net.) 27const ENABLED = true; 28 29export function createStreamRenderer(contentEl, { render, hljs } = {}) { 30 let started = false; 31 let tailMarker = null; // finalized nodes precede it; live-tail nodes follow it 32 let committedLen = 0; // chars of source already frozen 33 let lastText = ''; // most recent full text (for finalize) 34 let tailShownLen = 0; // rendered-text length of the live tail (drives token fade) 35 let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams 36 let degraded = !ENABLED; // true once we fall back to full re-render 37 38 function start() { 39 contentEl.textContent = ''; 40 tailMarker = document.createComment('tail'); 41 contentEl.appendChild(tailMarker); 42 started = true; 43 } 44 45 function highlight(root) { 46 if (hljs) root.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b)); 47 } 48 49 function clearTail() { 50 while (tailMarker.nextSibling) tailMarker.nextSibling.remove(); 51 } 52 53 // Render `src` and freeze the nodes before the tail marker. Highlighting happens 54 // here, once, on the detached fragment before the nodes are ever shown. 55 function freeze(src) { 56 const holder = document.createElement('div'); 57 holder.innerHTML = render(src); 58 highlight(holder); 59 while (holder.firstChild) contentEl.insertBefore(holder.firstChild, tailMarker); 60 } 61 62 // Re-render the live tail. An open trailing fence streams in append-mode. 63 function renderTail(tailText) { 64 const fence = tailText ? describeOpenFence(tailText) : null; 65 if (fence) { 66 appendOpenFence(tailText, fence); 67 return; 68 } 69 appendMode = null; 70 clearTail(); 71 if (!tailText) { 72 tailShownLen = 0; 73 return; 74 } 75 const holder = document.createElement('div'); 76 holder.innerHTML = render(tailText); 77 fadeNewText(holder, tailShownLen); 78 tailShownLen = holder.textContent.length; 79 while (holder.firstChild) contentEl.appendChild(holder.firstChild); 80 } 81 82 // Stream the body of an unterminated code fence by appending only the new 83 // characters to a stable <pre><code> text node â no re-parse, no re-highlight. 84 function appendOpenFence(tailText, fence) { 85 if (!appendMode) { 86 clearTail(); 87 const pre = document.createElement('pre'); 88 const code = document.createElement('code'); 89 if (fence.lang) code.className = `language-${fence.lang}`; 90 const textNode = document.createTextNode(''); 91 code.appendChild(textNode); 92 pre.appendChild(code); 93 contentEl.appendChild(pre); 94 appendMode = { codeText: textNode, appendedLen: 0 }; 95 tailShownLen = 0; // code is never faded; prose after the fence fades fresh 96 } 97 const code = tailText.slice(fence.contentStart); 98 if (code.length >
98 appendMode.appendedLen) { 99 appendMode.codeText.appendData(code.slice(appendMode.appendedLen)); 100 appendMode.appendedLen = code.length; 101 } 102 } 103 104 // Wrap tail text past `prevLen` characters in <span class="token-new"> for the 105 // streaming fade-in. Skips code (<pre>) and thinking blocks (.thinking-content). 106 // Note: the original chat.js helper checked `.think-content`, a class that exists 107 // nowhere in the app, so thinking text used to fade; matching the real 108 // `.thinking-content` corrects that. Operates on the detached fragment before insertion. 109 function fadeNewText(container, prevLen) { 110 if (!prevLen) return; 111 const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); 112 let count = 0; 113 const toWrap = []; 114 while (walker.nextNode()) { 115 const node = walker.currentNode; 116 const len = node.textContent.length; 117 if (count + len <= prevLen) { 118 count += len; 119 continue; 120 } 121 toWrap.push({ node, splitAt: count < prevLen ? prevLen - count : 0 }); 122 count += len; 123 } 124 for (const { node, splitAt } of toWrap) { 125 const parent = node.parentNode; 126 if (!parent || parent.closest('pre, .thinking-content')) continue; 127 const target = splitAt > 0 ? node.splitText(splitAt) : node; 128 const span = document.createElement('span'); 129 span.className = 'token-new'; 130 parent.replaceChild(span, target); 131 span.appendChild(target); 132 } 133 } 134 135 function fullRender(fullText) { 136 contentEl.innerHTML = render(fullText); 137 highlight(contentEl); 138 } 139 140 // Render the latest full source text. 141 // 142 // PRECONDITION: callers must pass append-only text â each call's `fullText` must 143 // extend the previous one with the already-seen prefix UNCHANGED. Finalized 144 // blocks are frozen and never re-rendered, so a feed that rewrites earlier text 145 // would leave stale frozen blocks (corrected only by the next full re-render). 146 // chat.js satisfies this: its stripToolBlocks output only strips not-yet-finalized 147 // trailing tool syntax, never text that has already been frozen. 148 function update(fullText) { 149 lastText = fullText; 150 if (degraded) { 151 fullRender(fullText); 152 return; 153 } 154 try { 155 // Self-heal: if our DOM was replaced out from under us â chat.js writes 156 // contentEl.innerHTML directly for thinking indicators and tool blocks, and 157 // finalize() removes the marker â our tail marker is no longer a child of the 158 // container. Rebuild from scratch so we never append onto foreign content or 159 // touch a detached marker. 160 if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) { 161 started = false; 162 committedLen = 0; 163 tailShownLen = 0; 164 appendMode = null; 165 } 166 if (!started) start(); 167 const next = splitFinalized(fullText, render, committedLen); 168 if (next > committedLen) { 169 freeze(fullText.slice(committedLen, next)); 170 committedLen = next; 171 appendMode = null; // whatever was streaming is now frozen 172 tailShownLen = 0; 173 } 174 renderTail(fullText.slice(committedLen)); 175 } catch (err) { 176 degraded = true; 177 console.error('streamingRenderer: falling back to full render', err); 178 fullRender(fullText); 179 } 180 } 181 182 // Stream finished: freeze whatever is left canonically and flatten away the 183 // marker so the container holds exactly what a single full render would produce. 184 // chat.js currently re-renders the finished message from source for its own 185 // reasons and so doesn't call this, but it completes the renderer's lifecycle and 186 // is exercised by the tests. 187 function finalize() { 188 if (degraded) return; 189 try { 190 if (!started) start(); 191 clearTail(); 192 appendMode = null; 193 const rest = lastText.slice(committedLen); 194 if (rest.trim()) freeze(rest); 195 tailMarker.remove(); 196 tailMarker = null; 197 committedLen = lastText.length; 198 } catch (err) { 199 degraded = true; 200 console.error('streamingRenderer: falling back to full render', err); 201 fullRender(lastText); 202 } 203 } 204 205 return { update, finalize }; 206}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.