From 59d2f295c8990b1ee12ef54ea09df2ca23932155 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Sun, 16 Aug 2026 15:19:28 +0000 Subject: [PATCH 1/6] fix(stream): stop tearing live progress words across block boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparent-stream progress rows are built incrementally by `_anchorProseIncrementalNode`, which streams source text into the vendored streaming-markdown parser. That parser always holds the most recently written character in its pending buffer, so a live row's rendered `.msg-body` text trails its source text by one character. `_refreshTransparentFadeProseRow` resumed appending from the `data-stream-fade-text` cursor but fell back to `body.textContent` when the attribute was absent — exactly the case for incrementally built rows, which never set it. That fallback mixed two coordinate spaces (rendered text vs source text), so the computed delta started one character early, in the middle of a word. The delta was then appended as a sibling of the parser's open `

`, and since `

` is a block box the tail rendered on its own line: a single word split across two lines ("Ces deu" / "x fichiers"). Two independent fixes: 1. Resume strictly from the source-space cursor. When no cursor exists we cannot know how much source is already rendered, so rebuild the body instead of guessing a delta from rendered text. 2. Append into the trailing block element rather than the `.msg-body` root, so a continuation of the current sentence stays on the same line even if a cursor is ever stale. Regression tests drive the real vendored parser to build a live row exactly as production does, assert the parser really does lag its source, and fail if any word is torn across a block boundary. The existing reconcile harness gains the new helper dependency. --- static/ui.js | 36 +- ...ssue5367_transparent_live_row_reconcile.py | 7 + ...est_live_prose_fade_cursor_source_space.py | 342 ++++++++++++++++++ 3 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 tests/test_live_prose_fade_cursor_source_space.py diff --git a/static/ui.js b/static/ui.js index 56af87da..562d0641 100644 --- a/static/ui.js +++ b/static/ui.js @@ -13792,6 +13792,10 @@ function _refreshTransparentThinkingLiveRow(existing, node){ return true; } +const _TRANSPARENT_FADE_BLOCK_TAGS = new Set([ + 'P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE', +]); + function _bindTransparentFadeCleanup(body){ if(!body || body._transparentFadeCleanupBound || typeof body.addEventListener !== 'function') return; body._transparentFadeCleanupBound = true; @@ -13808,6 +13812,20 @@ function _bindTransparentFadeCleanup(body){ }); } +// Trailing block element of a live prose body, if any. Live rows are produced +// by the incremental streaming-markdown path, which keeps an open block (a

) +// as it parses; text appended as a sibling of that block would render on its +// own line. Inline wrappers (fade spans) are not block boxes and are skipped. +function _transparentFadeAppendTarget(body){ + if(!body) return body; + const kids = body.childNodes; + if(!kids || !kids.length) return body; + const last = kids[kids.length - 1]; + if(!last || last.nodeType !== 1) return body; + const tag = String(last.tagName || '').toUpperCase(); + return _TRANSPARENT_FADE_BLOCK_TAGS.has(tag) ? last : body; +} + function _appendTransparentFadeText(body, text){ if(!body) return; const value = String(text || ''); @@ -13833,13 +13851,25 @@ function _appendTransparentFadeText(body, text){ } if(!changed) frag.appendChild(document.createTextNode(value)); else if(last < value.length) frag.appendChild(document.createTextNode(value.slice(last))); - body.appendChild(frag); + // Append inside the trailing block element (the streaming markdown parser's + // open

) rather than at the root of .msg-body. A block sibling would start + // its own line box, so a continuation of the current sentence — in particular + // the tail of a word — must land inside that block to stay on the same line. + _transparentFadeAppendTarget(body).appendChild(frag); } function _refreshTransparentFadeProseRow(existing, node, preservedState){ let body = existing.querySelector ? existing.querySelector('.msg-body') : null; const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || '')); - const currentText = String(existing.getAttribute('data-stream-fade-text') || (body && body.textContent) || ''); + // Resume strictly from the source-space cursor. `body.textContent` is NOT a + // valid substitute: rows built by the incremental streaming-markdown path + // render one character behind their source text (the parser holds the last + // character pending), so falling back to rendered text yields a delta that + // starts mid-word and tears the word in half. With no cursor we cannot know + // how much of the source is already rendered, so rebuild the body instead of + // guessing a delta. + const hasCursor = !!(existing.getAttribute && existing.getAttribute('data-stream-fade-text') !== null); + const currentText = hasCursor ? String(existing.getAttribute('data-stream-fade-text') || '') : ''; const pairs = _transparentLiveRowAttributePairs(node); const kept = Object.create(null); for(const pair of pairs){ @@ -13860,7 +13890,7 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ existing.appendChild(body); } if(body.classList) body.classList.add('stream-fade-active'); - if(!nextText.startsWith(currentText)){ + if(!hasCursor || !nextText.startsWith(currentText)){ body.textContent = ''; existing.setAttribute('data-stream-fade-text', ''); _appendTransparentFadeText(body, nextText); diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index c3669f7c..4be6eb42 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -308,6 +308,13 @@ eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_bindTransparentFadeCleanup')); +// `_appendTransparentFadeText` now delegates its insertion point to +// `_transparentFadeAppendTarget`, so the extraction harness must provide it +// (and the block-tag set it consults) or the extracted function throws. +global._TRANSPARENT_FADE_BLOCK_TAGS = new Set([ + 'P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE', +]); +eval(extractFunc('_transparentFadeAppendTarget')); eval(extractFunc('_appendTransparentFadeText')); eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentLiveRow')); diff --git a/tests/test_live_prose_fade_cursor_source_space.py b/tests/test_live_prose_fade_cursor_source_space.py new file mode 100644 index 00000000..d574effd --- /dev/null +++ b/tests/test_live_prose_fade_cursor_source_space.py @@ -0,0 +1,342 @@ +"""Browserless regression: live prose fade rows must not split words across blocks. + +Context +------- +Transparent-stream progress rows are built incrementally by +``_anchorProseIncrementalNode`` (static/messages.js), which streams the source +text into the vendored streaming-markdown parser. That parser always holds the +most recently written character back in its pending buffer, so the rendered +``.msg-body`` text lags the source text by one character while the row is live. + +``_refreshTransparentFadeProseRow`` (static/ui.js) resumes appending from the +``data-stream-fade-text`` cursor, and used to fall back to ``body.textContent`` +when that attribute was missing -- which is exactly the case for incrementally +built rows. The fallback mixed two different coordinate spaces (rendered text +vs source text), so the computed delta started one character early, in the +middle of a word, and was appended as a *sibling* of the parser's ``

`` +element instead of inside it. A ``

`` is a block box, so the tail rendered on +its own line and the word was visually torn in half ("Ces deu" / "x fichiers"). + +These tests drive the real vendored parser to build the live row exactly like +production does, then run the real reconciler functions over it. +""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +NODE = shutil.which("node") + +# Streamed source text, split exactly like the SSE token deltas that produced +# the original report. +FIRST_DELTA = "Ces" +SECOND_DELTA = " deux fichiers passent." +FULL_TEXT = FIRST_DELTA + SECOND_DELTA + + +def _run_node_module(script): + assert NODE, "node is required for DOM-executed live prose render tests" + env = os.environ.copy() + env["UI_JS_PATH"] = str(ROOT / "static" / "ui.js") + env["SMD_PATH"] = str(ROOT / "static" / "vendor" / "smd.min.js") + result = subprocess.run( + [NODE, "--input-type=module", "-e", script], + env=env, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +# A DOM stub faithful enough for this contract: textContent is *computed* from +# the node tree (the production code reads it back), and block-vs-inline +# structure is preserved so a torn word is observable. +_HARNESS = r""" +import { readFileSync } from 'node:fs'; +const src = readFileSync(process.env.UI_JS_PATH, 'utf8'); +function extractFunc(name){ + const marker = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(marker); + if(start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start) + 1; + let depth = 1; + while(depth > 0 && i < src.length){ + if(src[i] === '{') depth += 1; + else if(src[i] === '}') depth -= 1; + i += 1; + } + return src.slice(start, i); +} +const BLOCK_TAGS = new Set(['P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE']); +class TextNode { + constructor(t){ this.nodeType = 3; this.tagName = '#text'; this._t = String(t); this.parentNode = null; this.childNodes = []; } + get textContent(){ return this._t; } + set textContent(v){ this._t = String(v); } +} +function extractConst(name){ + const marker = new RegExp('const\\s+' + name + '\\s*='); + const start = src.search(marker); + if(start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('[', start) + 1; + let depth = 1; + while(depth > 0 && i < src.length){ + if(src[i] === '[') depth += 1; + else if(src[i] === ']') depth -= 1; + i += 1; + } + const end = src.indexOf(';', i); + return src.slice(start, end + 1); +} +class FakeElement { + constructor(tag='div'){ + this.nodeType = 1; + this.tagName = String(tag).toUpperCase(); + this.childNodes = []; + this.attributes = Object.create(null); + this.dataset = Object.create(null); + this.style = { setProperty(){}, removeProperty(){} }; + this.parentNode = null; + this.type = ''; + this.disabled = false; + this._classes = new Set(); + const self = this; + this.classList = { + add(...n){ n.forEach(x=>self._classes.add(x)); }, + remove(...n){ n.forEach(x=>self._classes.delete(x)); }, + contains(n){ return self._classes.has(n); }, + toggle(n, force){ + if(force === true){ self._classes.add(n); return true; } + if(force === false){ self._classes.delete(n); return false; } + if(self._classes.has(n)){ self._classes.delete(n); return false; } + self._classes.add(n); return true; + }, + }; + } + get children(){ return this.childNodes.filter(n=>n.nodeType === 1); } + get parentElement(){ return this.parentNode; } + get lastChild(){ return this.childNodes[this.childNodes.length - 1] || null; } + get className(){ return Array.from(this._classes).join(' '); } + set className(v){ this._classes = new Set(String(v ?? '').trim().split(/\s+/).filter(Boolean)); } + get textContent(){ return this.childNodes.map(n=>n.textContent).join(''); } + set textContent(v){ + this.childNodes = []; + const s = String(v ?? ''); + if(s) this.appendChild(new TextNode(s)); + } + appendChild(n){ + if(n && n._isFragment){ n.childNodes.slice().forEach(c=>this.appendChild(c)); return n; } + if(n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); } + n.parentNode = this; this.childNodes.push(n); return n; + } + setAttribute(name, value){ this.attributes[String(name)] = String(value ?? ''); } + getAttribute(name){ const k = String(name); return k in this.attributes ? this.attributes[k] : null; } + removeAttribute(name){ delete this.attributes[String(name)]; } + getAttributeNames(){ return Object.keys(this.attributes); } + addEventListener(){} + querySelector(sel){ + const want = String(sel).split(',')[0].trim().replace(/^\./, ''); + const walk = (node)=>{ + for(const c of node.childNodes){ + if(c.nodeType !== 1) continue; + if(c._classes.has(want)) return c; + const hit = walk(c); + if(hit) return hit; + } + return null; + }; + return walk(this); + } + querySelectorAll(){ return []; } +} +class Fragment extends FakeElement { + constructor(){ super('#fragment'); this._isFragment = true; } +} +globalThis.document = { + createElement:(t)=>new FakeElement(t), + createTextNode:(t)=>new TextNode(t), + createDocumentFragment:()=>new Fragment(), +}; +globalThis.window = { matchMedia: ()=>({ matches: false }) }; + +// Report whether any word is torn across a block boundary inside .msg-body, +// i.e. a block child whose text ends mid-word immediately followed by a +// sibling whose text starts mid-word. +function midWordBlockSplit(body){ + const kids = body.childNodes; + for(let i = 0; i < kids.length - 1; i++){ + const cur = kids[i]; + const next = kids[i + 1]; + const isBlock = cur.nodeType === 1 && BLOCK_TAGS.has(cur.tagName); + if(!isBlock) continue; + const before = cur.textContent; + const after = next.textContent; + if(/\S$/.test(before) && /^\S/.test(after)){ + return { split: true, before: before.slice(-12), after: after.slice(0, 12) }; + } + } + return { split: false, before: '', after: '' }; +} + +// Non-whitespace text stranded at the root of .msg-body after a block element. +function strandedAfterBlock(body){ + let seenBlock = false, stranded = ''; + for(const kid of body.childNodes){ + if(kid.nodeType === 1 && BLOCK_TAGS.has(kid.tagName)){ seenBlock = true; continue; } + if(seenBlock) stranded += kid.textContent; + } + return stranded.trim(); +} +""" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_row_without_cursor_keeps_words_intact(): + """A live row built incrementally must never be torn mid-word on refresh.""" + script = ( + _HARNESS + + """ +const smd = await import(process.env.SMD_PATH); +(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS')); +(0, eval)(extractFunc('_transparentFadeAppendTarget')); +(0, eval)(extractFunc('_bindTransparentFadeCleanup')); +(0, eval)(extractFunc('_appendTransparentFadeText')); +(0, eval)(extractFunc('_transparentLiveRowAttributePairs')); +(0, eval)(extractFunc('_transparentLiveRowInteractiveState')); +(0, eval)(extractFunc('_rehydrateTransparentLiveRow')); +(0, eval)(extractFunc('_refreshTransparentFadeProseRow')); + +const FIRST = %s; +const SECOND = %s; +const FULL = FIRST + SECOND; + +// Live row exactly as _anchorProseIncrementalNode builds it: the real parser +// streams the source in, and is deliberately NOT ended (the turn is live). +const existing = new FakeElement('div'); +existing.className = 'assistant-segment transparent-event-row'; +existing.setAttribute('data-anchor-row-role', 'prose'); +existing.setAttribute('data-anchor-row-id', 'live-prose-row'); +existing.setAttribute('data-anchor-source-event-type', 'process_prose'); +const body = new FakeElement('div'); +body.className = 'msg-body stream-fade-active'; +existing.appendChild(body); +const parser = smd.parser(smd.default_renderer(body)); +smd.parser_write(parser, FIRST); +smd.parser_write(parser, SECOND.slice(0, 4)); +const renderedBefore = body.textContent; +const sourceBefore = FIRST + SECOND.slice(0, 4); + +// Candidate carrying the full source text for this row. +const candidate = new FakeElement('div'); +candidate.className = 'assistant-segment transparent-event-row'; +candidate.setAttribute('data-anchor-row-role', 'prose'); +candidate.setAttribute('data-anchor-row-id', 'live-prose-row'); +candidate.setAttribute('data-anchor-source-event-type', 'process_prose'); +candidate.dataset.rawText = FULL; +const candidateBody = new FakeElement('div'); +candidateBody.className = 'msg-body stream-fade-active'; +candidateBody.textContent = FULL; +candidate.appendChild(candidateBody); + +_refreshTransparentFadeProseRow(existing, candidate, null); + +const refreshedBody = existing.querySelector('.msg-body'); +const split = midWordBlockSplit(refreshedBody); +process.stdout.write(JSON.stringify({ + renderedLagsSource: renderedBefore.length < sourceBefore.length, + renderedBefore, + sourceBefore, + finalText: refreshedBody.textContent, + expectedText: FULL, + midWordSplit: split.split, + splitBefore: split.before, + splitAfter: split.after, + strandedAfterBlock: strandedAfterBlock(refreshedBody), + cursor: existing.getAttribute('data-stream-fade-text'), +})); +""" + % (json.dumps(FIRST_DELTA), json.dumps(SECOND_DELTA)) + ) + data = _run_node_module(script) + + # Precondition: the vendored parser really does lag the source text. + assert data["renderedLagsSource"] is True, ( + "expected the streaming parser to hold a pending character, got " + f"rendered={data['renderedBefore']!r} source={data['sourceBefore']!r}" + ) + + # The contract under test: no word may be torn across a block boundary. + assert data["midWordSplit"] is False, ( + "live prose row was torn mid-word across a block boundary: " + f"...{data['splitBefore']!r} | {data['splitAfter']!r}..." + ) + assert data["strandedAfterBlock"] == "", ( + "text was appended outside the block element instead of inside it: " + f"{data['strandedAfterBlock']!r}" + ) + # And the visible text stays exactly the source text. + assert data["finalText"] == data["expectedText"] + assert data["cursor"] == FULL_TEXT + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_row_with_cursor_still_appends_incrementally(): + """The existing incremental contract (cursor present) must be preserved.""" + script = ( + _HARNESS + + """ +(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS')); +(0, eval)(extractFunc('_transparentFadeAppendTarget')); +(0, eval)(extractFunc('_bindTransparentFadeCleanup')); +(0, eval)(extractFunc('_appendTransparentFadeText')); +(0, eval)(extractFunc('_transparentLiveRowAttributePairs')); +(0, eval)(extractFunc('_transparentLiveRowInteractiveState')); +(0, eval)(extractFunc('_rehydrateTransparentLiveRow')); +(0, eval)(extractFunc('_refreshTransparentFadeProseRow')); + +const existing = new FakeElement('div'); +existing.className = 'assistant-segment transparent-event-row'; +existing.setAttribute('data-anchor-row-role', 'prose'); +existing.setAttribute('data-stream-fade-text', 'old '); +const body = new FakeElement('div'); +body.className = 'msg-body stream-fade-active'; +const oldSpan = new FakeElement('span'); +oldSpan.className = 'stream-fade-word is-new'; +oldSpan.textContent = 'old'; +body.appendChild(oldSpan); +body.appendChild(new TextNode(' ')); +existing.appendChild(body); + +const candidate = new FakeElement('div'); +candidate.className = 'assistant-segment transparent-event-row'; +candidate.setAttribute('data-anchor-row-role', 'prose'); +candidate.dataset.rawText = 'old new'; +const candidateBody = new FakeElement('div'); +candidateBody.className = 'msg-body stream-fade-active'; +candidateBody.textContent = 'old new'; +candidate.appendChild(candidateBody); + +_refreshTransparentFadeProseRow(existing, candidate, null); +const refreshedBody = existing.querySelector('.msg-body'); +const spans = refreshedBody.children.filter(c=>c._classes.has('stream-fade-word')); +process.stdout.write(JSON.stringify({ + text: refreshedBody.textContent, + cursor: existing.getAttribute('data-stream-fade-text'), + spanTexts: spans.map(s=>s.textContent), + oldSpanPreserved: spans[0] === oldSpan, +})); +""" + ) + data = _run_node_module(script) + assert data["text"] == "old new" + assert data["cursor"] == "old new" + assert data["spanTexts"] == ["old", "new"] + # Node identity of already-faded words must survive (scroll-anchor stability). + assert data["oldSpanPreserved"] is True From 34f10d25457aa8669fb8d9a5c82725fa02806927 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Mon, 17 Aug 2026 22:42:48 +0000 Subject: [PATCH 2/6] fix(stream): mute rendered prefix and keep parsed markdown on fade-row rebuild Review follow-ups for #7082: - Maintainer should-fix: the no-cursor rebuild branch of _refreshTransparentFadeProseRow cleared the body and re-wrapped every word as .is-new, so the whole visible row dipped to opacity 0 and faded back (~620ms), and wholesale node replacement invited the one-time scroll-anchor bounce (#6257). Now the rendered text is snapshotted before the clear and the messages.js _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild, so only genuinely-new tail words animate. The helper is exported on window (__streamFadeMuteRenderedPrefix) the same way __anchorProseIncrementalNode is. - Greptile P1: the same branch passed raw source text to _appendTransparentFadeText, flattening live markdown rows to literal syntax (links/emphasis/headings lost until settlement). The rebuild now adopts a deep clone of the candidate's parsed .msg-body (the incremental streaming-markdown output) when one exists, so the parsed DOM survives; the plain-text rebuild remains only as fallback for candidates without a parsed body. The resume cursor stays in source space when rendered text diverges from source (markdown), and shrinks to the rendered prefix for plain prose so the parser's held-back character is re-appended on the next delta instead of dropped. Regression tests: rebuild must not re-animate the already-rendered prefix (tail-only .is-new), and rebuild must keep parsed / DOM with no literal markdown in the visible text. The two existing tests of this PR are unchanged and still pass. --- static/messages.js | 7 + static/ui.js | 47 +++- ...est_live_prose_fade_cursor_source_space.py | 223 +++++++++++++++++- 3 files changed, 267 insertions(+), 10 deletions(-) diff --git a/static/messages.js b/static/messages.js index 77d8cf08..481c91b7 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5076,6 +5076,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; _walk(rootEl); } + // Exposed for the transparent-stream fade prose reconciler in ui.js + // (same pattern as __anchorProseIncrementalNode above): the no-cursor + // rebuild branch of _refreshTransparentFadeProseRow snapshots the rendered + // text before clearing and re-applies this mute so only genuinely-new tail + // words animate (#7082 review). The helper is stateless, so unlike + // __anchorProseIncrementalNode it never needs to be cleared per-stream. + if(typeof window!=='undefined') window.__streamFadeMuteRenderedPrefix=_streamFadeMuteRenderedPrefix; function _streamFadePauseAfter(text, paragraphBreakIndex){ if(paragraphBreakIndex>=0) return 90; const trimmed=String(text||'').trimEnd(); diff --git a/static/ui.js b/static/ui.js index 562d0641..4f8407f7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -13891,13 +13891,54 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ } if(body.classList) body.classList.add('stream-fade-active'); if(!hasCursor || !nextText.startsWith(currentText)){ - body.textContent = ''; + // Rebuild branch. Snapshot the rendered text BEFORE clearing (#7082 + // review should-fix): without it every word re-wraps as `.is-new`, the + // whole visible row dips to opacity 0 and fades back (~620ms), and the + // wholesale node replacement invites the one-time scroll-anchor bounce + // the #6257 comment in _bindTransparentFadeCleanup warns about. + const prevRendered = String(body.textContent || ''); existing.setAttribute('data-stream-fade-text', ''); - _appendTransparentFadeText(body, nextText); + // Markdown preservation (Greptile P1): prefer cloning the candidate's own + // `.msg-body` DOM over a plain source-text rebuild. For live rows the + // candidate is the incremental streaming-markdown node, so its body is + // the markdown pipeline's parsed output — links, emphasis, headings, + // lists and code stay rendered instead of the row flattening to literal + // markdown source until settlement. + const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null; + let resumeCursor = nextText; + if(candidateBody && candidateBody !== body && + typeof candidateBody.cloneNode === 'function' && + candidateBody.childNodes && candidateBody.childNodes.length){ + const clone = candidateBody.cloneNode(true); + body.textContent = ''; + while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]); + _bindTransparentFadeCleanup(body); + // The streaming parser holds a pending tail, so the adopted DOM can lag + // the source text. When the rendered text is a strict prefix of the + // source (plain prose), resume the cursor from it so the held-back + // characters arrive with the next delta instead of being dropped; + // otherwise (markdown: rendered text diverges from source) keep the + // full source-space cursor — the append contract stays source-space. + const renderedNow = String(body.textContent || ''); + if(renderedNow && nextText.startsWith(renderedNow)) resumeCursor = renderedNow; + }else{ + body.textContent = ''; + _appendTransparentFadeText(body, nextText); + } + // messages.js _streamFadeMuteRenderedPrefix idiom (exported on window the + // same way __anchorProseIncrementalNode is): rendered-space compare of the + // pre-rebuild snapshot against the rebuilt body, stripping `.is-new` from + // spans inside the common prefix so already-visible words do not replay + // their fade — only genuinely-new tail words animate. + const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function') + ? window.__streamFadeMuteRenderedPrefix + : null; + if(mute && prevRendered) mute(body, prevRendered); + existing.setAttribute('data-stream-fade-text', resumeCursor); }else{ _appendTransparentFadeText(body, nextText.slice(currentText.length)); + existing.setAttribute('data-stream-fade-text', nextText); } - existing.setAttribute('data-stream-fade-text', nextText); _rehydrateTransparentLiveRow(existing, node, preservedState); return existing; } diff --git a/tests/test_live_prose_fade_cursor_source_space.py b/tests/test_live_prose_fade_cursor_source_space.py index d574effd..57481af1 100644 --- a/tests/test_live_prose_fade_cursor_source_space.py +++ b/tests/test_live_prose_fade_cursor_source_space.py @@ -45,6 +45,7 @@ def _run_node_module(script): env = os.environ.copy() env["UI_JS_PATH"] = str(ROOT / "static" / "ui.js") env["SMD_PATH"] = str(ROOT / "static" / "vendor" / "smd.min.js") + env["MESSAGES_JS_PATH"] = str(ROOT / "static" / "messages.js") result = subprocess.run( [NODE, "--input-type=module", "-e", script], env=env, @@ -62,24 +63,26 @@ def _run_node_module(script): _HARNESS = r""" import { readFileSync } from 'node:fs'; const src = readFileSync(process.env.UI_JS_PATH, 'utf8'); -function extractFunc(name){ +function extractFuncFrom(source, name){ const marker = new RegExp('function\\s+' + name + '\\s*\\('); - const start = src.search(marker); + const start = source.search(marker); if(start < 0) throw new Error(name + ' not found'); - let i = src.indexOf('{', start) + 1; + let i = source.indexOf('{', start) + 1; let depth = 1; - while(depth > 0 && i < src.length){ - if(src[i] === '{') depth += 1; - else if(src[i] === '}') depth -= 1; + while(depth > 0 && i < source.length){ + if(source[i] === '{') depth += 1; + else if(source[i] === '}') depth -= 1; i += 1; } - return src.slice(start, i); + return source.slice(start, i); } +function extractFunc(name){ return extractFuncFrom(src, name); } const BLOCK_TAGS = new Set(['P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE']); class TextNode { constructor(t){ this.nodeType = 3; this.tagName = '#text'; this._t = String(t); this.parentNode = null; this.childNodes = []; } get textContent(){ return this._t; } set textContent(v){ this._t = String(v); } + cloneNode(){ return new TextNode(this._t); } } function extractConst(name){ const marker = new RegExp('const\\s+' + name + '\\s*='); @@ -141,6 +144,14 @@ class FakeElement { removeAttribute(name){ delete this.attributes[String(name)]; } getAttributeNames(){ return Object.keys(this.attributes); } addEventListener(){} + cloneNode(deep){ + const copy = new FakeElement(this.tagName); + copy._classes = new Set(this._classes); + for(const k of Object.keys(this.attributes)) copy.attributes[k] = this.attributes[k]; + for(const k of Object.keys(this.dataset)) copy.dataset[k] = this.dataset[k]; + if(deep) this.childNodes.forEach(c=>copy.appendChild(c.cloneNode(true))); + return copy; + } querySelector(sel){ const want = String(sel).split(',')[0].trim().replace(/^\./, ''); const walk = (node)=>{ @@ -340,3 +351,201 @@ process.stdout.write(JSON.stringify({ assert data["spanTexts"] == ["old", "new"] # Node identity of already-faded words must survive (scroll-anchor stability). assert data["oldSpanPreserved"] is True + + +# Shared eval preamble for the rebuild-branch tests: real ui.js reconciler +# functions plus the real messages.js mute helper, wired to window exactly the +# way production wires it (window.__streamFadeMuteRenderedPrefix). +_REBUILD_PRELUDE = """ +const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8'); +(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS')); +(0, eval)(extractFunc('_transparentFadeAppendTarget')); +(0, eval)(extractFunc('_bindTransparentFadeCleanup')); +(0, eval)(extractFunc('_appendTransparentFadeText')); +(0, eval)(extractFunc('_transparentLiveRowAttributePairs')); +(0, eval)(extractFunc('_transparentLiveRowInteractiveState')); +(0, eval)(extractFunc('_rehydrateTransparentLiveRow')); +(0, eval)(extractFunc('_refreshTransparentFadeProseRow')); +(0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix')); +globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix; +if(typeof window.__streamFadeMuteRenderedPrefix !== 'function'){ + throw new Error('messages.js must export _streamFadeMuteRenderedPrefix on window'); +} +function makeRow(rawText){ + const row = new FakeElement('div'); + row.className = 'assistant-segment transparent-event-row'; + row.setAttribute('data-anchor-row-role', 'prose'); + row.setAttribute('data-anchor-row-id', 'live-prose-row'); + row.setAttribute('data-anchor-source-event-type', 'process_prose'); + if(rawText !== undefined) row.dataset.rawText = rawText; + const rowBody = new FakeElement('div'); + rowBody.className = 'msg-body stream-fade-active'; + row.appendChild(rowBody); + return row; +} +function collectFadeSpans(root){ + const out = []; + const walk = (node)=>{ + for(const c of node.childNodes || []){ + if(c.nodeType !== 1) continue; + if(c._classes && c._classes.has('stream-fade-word')){ + out.push({ text: c.textContent, isNew: c._classes.has('is-new') }); + } + walk(c); + } + }; + walk(root); + return out; +} +function findTag(root, tag){ + for(const c of root.childNodes || []){ + if(c.nodeType !== 1) continue; + if(c.tagName === tag) return c; + const hit = findTag(c, tag); + if(hit) return hit; + } + return null; +} +""" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_rebuild_mutes_already_rendered_prefix(): + """#7082 review should-fix: the no-cursor rebuild must not re-animate the + already-rendered prefix. Only genuinely-new tail words keep ``.is-new``.""" + script = ( + _HARNESS + + _REBUILD_PRELUDE + + """ +const smd = await import(process.env.SMD_PATH); + +const FIRST = %s; +const SECOND = %s; +const FULL = FIRST + SECOND; + +// Existing row: incrementally built by the real parser, no cursor attribute. +const existing = makeRow(undefined); +delete existing.dataset.rawText; +const body = existing.querySelector('.msg-body'); +const parser = smd.parser(smd.default_renderer(body)); +smd.parser_write(parser, FIRST); +smd.parser_write(parser, SECOND.slice(0, 4)); +const prevRendered = body.textContent; + +// Candidate: the incremental fade node's parsed body — a

whose words are +// wrapped as fade spans, all `.is-new` (worst case: a rewind rebuild of the +// incremental node re-wrapped every word). +const candidate = makeRow(FULL); +const candidateBody = candidate.querySelector('.msg-body'); +const p = new FakeElement('p'); +candidateBody.appendChild(p); +const words = FULL.split(' '); +words.forEach((word, i)=>{ + const span = new FakeElement('span'); + span.className = 'stream-fade-word is-new'; + span.textContent = word; + p.appendChild(span); + if(i < words.length - 1) p.appendChild(new TextNode(' ')); +}); + +_refreshTransparentFadeProseRow(existing, candidate, null); + +const refreshedBody = existing.querySelector('.msg-body'); +process.stdout.write(JSON.stringify({ + prevRendered, + finalText: refreshedBody.textContent, + spans: collectFadeSpans(refreshedBody), + cursor: existing.getAttribute('data-stream-fade-text'), + candidateBodyIntact: candidateBody.childNodes.length === 1 && candidateBody.childNodes[0] === p, +})); +""" + % (json.dumps(FIRST_DELTA), json.dumps(SECOND_DELTA)) + ) + data = _run_node_module(script) + + assert data["finalText"] == FULL_TEXT + assert data["cursor"] == FULL_TEXT + spans = data["spans"] + assert [s["text"] for s in spans] == FULL_TEXT.split(" ") + # Words inside the previously-rendered prefix must NOT replay their fade … + prev = data["prevRendered"] + consumed = 0 + for span in spans: + starts_in_prefix = consumed < len(prev) + if starts_in_prefix: + assert span["isNew"] is False, ( + f"already-rendered word {span['text']!r} would replay its fade " + f"(prefix was {prev!r})" + ) + consumed += len(span["text"]) + 1 # word + following space + # … while genuinely-new tail words still animate. + assert spans[-1]["isNew"] is True + muted = [s for s in spans if not s["isNew"]] + assert muted, "expected at least one muted already-rendered word" + # The persistent incremental node (parser-owned DOM) must not be stolen. + assert data["candidateBodyIntact"] is True + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_rebuild_preserves_markdown_dom(): + """Greptile P1: the no-cursor rebuild must not flatten a live markdown row + to literal source text — the parsed DOM (links, emphasis) must survive.""" + md_source = "Voici **un** [lien](https://example.com) utile pour tout le monde." + script = ( + _HARNESS + + _REBUILD_PRELUDE + + """ +const smd = await import(process.env.SMD_PATH); + +const SOURCE = %s; + +// Existing row: the parser has only seen a prefix of the source. +const existing = makeRow(undefined); +delete existing.dataset.rawText; +const body = existing.querySelector('.msg-body'); +const parser = smd.parser(smd.default_renderer(body)); +smd.parser_write(parser, SOURCE.slice(0, 10)); +const prevRendered = body.textContent; + +// Candidate: the incremental node fed the FULL source through the real +// streaming-markdown parser (still live, deliberately not ended). +const candidate = makeRow(SOURCE); +const candidateBody = candidate.querySelector('.msg-body'); +const candidateParser = smd.parser(smd.default_renderer(candidateBody)); +smd.parser_write(candidateParser, SOURCE); +const candidateRendered = candidateBody.textContent; + +_refreshTransparentFadeProseRow(existing, candidate, null); + +const refreshedBody = existing.querySelector('.msg-body'); +const link = findTag(refreshedBody, 'A'); +const strong = findTag(refreshedBody, 'STRONG'); +process.stdout.write(JSON.stringify({ + prevRendered, + candidateRendered, + finalText: refreshedBody.textContent, + hasLink: !!link, + linkText: link ? link.textContent : null, + linkHref: link ? link.getAttribute('href') : null, + hasStrong: !!strong, + strongText: strong ? strong.textContent : null, + cursor: existing.getAttribute('data-stream-fade-text'), +})); +""" + % json.dumps(md_source) + ) + data = _run_node_module(script) + + # The parsed markdown DOM survived the rebuild … + assert data["hasLink"] is True, "rebuild dropped the parsed element" + assert data["linkText"] == "lien" + assert data["linkHref"] == "https://example.com" + assert data["hasStrong"] is True, "rebuild dropped the parsed element" + assert data["strongText"] == "un" + # … and no markdown syntax leaked into the visible text. + assert "**" not in data["finalText"] + assert "](" not in data["finalText"] + assert data["finalText"] == data["candidateRendered"] + # Rendered text diverges from source here, so the cursor must stay in + # source space (the append contract of the cursor branch). + assert data["cursor"] == md_source From 259631e46b27cf2264cab56dbed98da6f62bd8b4 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Mon, 17 Aug 2026 23:10:36 +0000 Subject: [PATCH 3/6] fix(stream): resume cursor excludes parser pending tail on cursorless markdown rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 follow-up on the fade-row rebuild: when a cursorless live markdown row adopts the incremental node's cloned DOM, that DOM lags the source text by the streaming parser's held-back tail (pending/text buffers). Keeping the full source-length resume cursor made the next reconciliation append nothing, so the pending characters stayed missing from the live row until settlement. Read the held-back length from the parser bound on the candidate body (_smdBindParserIdentity) and trim the source-space cursor by exactly that length, so the cursor matches the adopted content and the next reconciliation re-appends the pending tail. Regression: rebuild while the parser holds an unrendered tail, then reconcile again — the pending text must appear in the live row before settlement. --- static/ui.js | 27 +++- ...est_live_prose_fade_cursor_source_space.py | 119 ++++++++++++++++-- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/static/ui.js b/static/ui.js index 4f8407f7..ed53c33f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -13916,11 +13916,30 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ // The streaming parser holds a pending tail, so the adopted DOM can lag // the source text. When the rendered text is a strict prefix of the // source (plain prose), resume the cursor from it so the held-back - // characters arrive with the next delta instead of being dropped; - // otherwise (markdown: rendered text diverges from source) keep the - // full source-space cursor — the append contract stays source-space. + // characters arrive with the next delta instead of being dropped. const renderedNow = String(body.textContent || ''); - if(renderedNow && nextText.startsWith(renderedNow)) resumeCursor = renderedNow; + if(renderedNow && nextText.startsWith(renderedNow)){ + resumeCursor = renderedNow; + }else{ + // Markdown (Greptile P1 follow-up): rendered text diverges from the + // source, so the rendered-prefix resume above cannot apply — but the + // cloned DOM still lags the source by the parser's held-back tail. A + // full source-length cursor would make the next reconciliation append + // nothing and the pending characters vanish until settlement. The + // parser state is bound on the incremental node's body by + // _smdBindParserIdentity (messages.js), and `pending`/`text` are its + // unrendered source-space buffers (`text` is flushed at the end of + // every parser_write; read defensively anyway). Trim the cursor by + // exactly that held-back length so the cursor matches what the clone + // actually renders and the tail re-appends with the next delta. + const liveParser = candidateBody.__smdParser; + const heldLen = liveParser + ? String(liveParser.pending || '').length + String(liveParser.text || '').length + : 0; + if(heldLen > 0 && heldLen <= nextText.length){ + resumeCursor = nextText.slice(0, nextText.length - heldLen); + } + } }else{ body.textContent = ''; _appendTransparentFadeText(body, nextText); diff --git a/tests/test_live_prose_fade_cursor_source_space.py b/tests/test_live_prose_fade_cursor_source_space.py index 57481af1..a5de2a69 100644 --- a/tests/test_live_prose_fade_cursor_source_space.py +++ b/tests/test_live_prose_fade_cursor_source_space.py @@ -358,14 +358,20 @@ process.stdout.write(JSON.stringify({ # way production wires it (window.__streamFadeMuteRenderedPrefix). _REBUILD_PRELUDE = """ const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8'); -(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS')); -(0, eval)(extractFunc('_transparentFadeAppendTarget')); -(0, eval)(extractFunc('_bindTransparentFadeCleanup')); -(0, eval)(extractFunc('_appendTransparentFadeText')); -(0, eval)(extractFunc('_transparentLiveRowAttributePairs')); -(0, eval)(extractFunc('_transparentLiveRowInteractiveState')); -(0, eval)(extractFunc('_rehydrateTransparentLiveRow')); -(0, eval)(extractFunc('_refreshTransparentFadeProseRow')); +// Single combined eval: sloppy-mode indirect eval hoists function declarations +// to the global object but discards top-level `const` bindings after the call, +// so _TRANSPARENT_FADE_BLOCK_TAGS must be evaluated in the SAME eval as the +// functions that close over it (_transparentFadeAppendTarget). +(0, eval)([ + extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'), + extractFunc('_transparentFadeAppendTarget'), + extractFunc('_bindTransparentFadeCleanup'), + extractFunc('_appendTransparentFadeText'), + extractFunc('_transparentLiveRowAttributePairs'), + extractFunc('_transparentLiveRowInteractiveState'), + extractFunc('_rehydrateTransparentLiveRow'), + extractFunc('_refreshTransparentFadeProseRow'), +].join('\\n')); (0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix')); globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix; if(typeof window.__streamFadeMuteRenderedPrefix !== 'function'){ @@ -508,12 +514,16 @@ smd.parser_write(parser, SOURCE.slice(0, 10)); const prevRendered = body.textContent; // Candidate: the incremental node fed the FULL source through the real -// streaming-markdown parser (still live, deliberately not ended). +// streaming-markdown parser (still live, deliberately not ended). Production +// binds the parser on the body via _smdBindParserIdentity (messages.js), and +// the cursor fix reads the held-back tail from that binding. const candidate = makeRow(SOURCE); const candidateBody = candidate.querySelector('.msg-body'); const candidateParser = smd.parser(smd.default_renderer(candidateBody)); smd.parser_write(candidateParser, SOURCE); +candidateBody.__smdParser = candidateParser; const candidateRendered = candidateBody.textContent; +const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || ''); _refreshTransparentFadeProseRow(existing, candidate, null); @@ -523,6 +533,7 @@ const strong = findTag(refreshedBody, 'STRONG'); process.stdout.write(JSON.stringify({ prevRendered, candidateRendered, + pendingTail, finalText: refreshedBody.textContent, hasLink: !!link, linkText: link ? link.textContent : null, @@ -546,6 +557,90 @@ process.stdout.write(JSON.stringify({ assert "**" not in data["finalText"] assert "](" not in data["finalText"] assert data["finalText"] == data["candidateRendered"] - # Rendered text diverges from source here, so the cursor must stay in - # source space (the append contract of the cursor branch). - assert data["cursor"] == md_source + # Rendered text diverges from source here, so the cursor stays in source + # space — but trimmed by the parser's held-back tail (Greptile P1 + # follow-up), so it matches what the adopted DOM actually renders. + assert data["pendingTail"] == "." + assert data["cursor"] == md_source[: -len(data["pendingTail"])] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_rebuild_cursor_excludes_parser_pending_tail(): + """Greptile P1 follow-up: on a cursorless markdown rebuild the adopted DOM + lags the source by the parser's pending tail. The resume cursor must match + the adopted content — not the full source length — so the next + reconciliation re-appends the held-back text before settlement.""" + md_source = "Voici **un** [lien](https://example.com) super." + script = ( + _HARNESS + + _REBUILD_PRELUDE + + """ +const smd = await import(process.env.SMD_PATH); + +const SOURCE = %s; + +// Existing row: cursorless, the parser has only seen a prefix of the source. +const existing = makeRow(undefined); +delete existing.dataset.rawText; +const body = existing.querySelector('.msg-body'); +const parser = smd.parser(smd.default_renderer(body)); +smd.parser_write(parser, SOURCE.slice(0, 8)); + +// Candidate: the persistent incremental node, full source written, parser +// still live (not ended) and bound on the body exactly like +// _smdBindParserIdentity does in production. +const candidate = makeRow(SOURCE); +const candidateBody = candidate.querySelector('.msg-body'); +const candidateParser = smd.parser(smd.default_renderer(candidateBody)); +smd.parser_write(candidateParser, SOURCE); +candidateBody.__smdParser = candidateParser; +const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || ''); +const candidateRendered = candidateBody.textContent; + +// First reconciliation: cursorless rebuild adopts the cloned parsed DOM. +_refreshTransparentFadeProseRow(existing, candidate, null); +const afterRebuildText = existing.querySelector('.msg-body').textContent; +const afterRebuildCursor = existing.getAttribute('data-stream-fade-text'); + +// Next reconciliation of the SAME live row (no new source text needed — the +// scene re-renders every frame). With a correct cursor this appends the +// held-back tail; with a full source-length cursor it appends nothing and the +// pending text stays missing until settlement. +_refreshTransparentFadeProseRow(existing, candidate, null); +const afterResumeBody = existing.querySelector('.msg-body'); + +process.stdout.write(JSON.stringify({ + pendingTail, + candidateRendered, + afterRebuildText, + afterRebuildCursor, + afterResumeText: afterResumeBody.textContent, + afterResumeCursor: existing.getAttribute('data-stream-fade-text'), +})); +""" + % json.dumps(md_source) + ) + data = _run_node_module(script) + + # Precondition: the live parser really holds a non-empty pending tail, and + # the adopted DOM therefore lags the source text. + assert data["pendingTail"], "expected the streaming parser to hold a pending tail" + assert data["pendingTail"] == "." + assert data["afterRebuildText"] == data["candidateRendered"] + assert not data["afterRebuildText"].endswith(data["pendingTail"]) + + # The fix under test: the resume cursor matches the adopted content, i.e. + # source minus the parser's held-back tail — not the full source length. + assert data["afterRebuildCursor"] == md_source[: -len(data["pendingTail"])], ( + "resumeCursor must exclude the parser's pending tail; got " + f"{data['afterRebuildCursor']!r}" + ) + + # Regression: the next reconciliation re-appends the pending text, so it is + # visible in the live row BEFORE settlement. + assert data["afterResumeText"] == data["candidateRendered"] + data["pendingTail"], ( + "pending text never reached the live row: " + f"{data['afterResumeText']!r}" + ) + assert data["afterResumeText"].endswith("super.") + assert data["afterResumeCursor"] == md_source From 9388c6495b133c301d60abfe1ccb993a9c6153d1 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Tue, 18 Aug 2026 19:39:25 +0000 Subject: [PATCH 4/6] fix(stream): keep parser DOM as fade-row authority after rebuild Cursorless markdown rebuilds cloned the live parser tree, then later source growth appended raw bytes through _appendTransparentFadeText. Incomplete emphasis (Hello ** / Hello **world**) became literal **world** while the candidate already had world. Parser-owned candidates now keep reconciling from the parsed DOM. Pending and MEDIA tails stay on the parser. Rendered-prefix mute is unchanged. --- static/ui.js | 67 +++---- ...est_live_prose_fade_cursor_source_space.py | 182 ++++++++++++++---- 2 files changed, 176 insertions(+), 73 deletions(-) diff --git a/static/ui.js b/static/ui.js index ed53c33f..aabb4c7b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -13890,21 +13890,36 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ existing.appendChild(body); } if(body.classList) body.classList.add('stream-fade-active'); - if(!hasCursor || !nextText.startsWith(currentText)){ + const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null; + const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser); + const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function') + ? window.__streamFadeMuteRenderedPrefix + : null; + if(parserOwned && typeof candidateBody.cloneNode === 'function'){ + // One authority after the first parser-owned rebuild: keep adopting the + // live parsed DOM. Cloning once and then appending later source bytes + // through `_appendTransparentFadeText` turns `Hello **` / `Hello **world**` + // into literal `**world**` while the candidate already has + // `world`. Pending and MEDIA tails stay on the parser + // until it flushes them into this candidate. + const prevRendered = String(body.textContent || ''); + const clone = candidateBody.cloneNode(true); + body.textContent = ''; + while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]); + _bindTransparentFadeCleanup(body); + if(mute && prevRendered) mute(body, prevRendered); + if(existing.removeAttribute) existing.removeAttribute('data-stream-fade-text'); + }else if(!hasCursor || !nextText.startsWith(currentText)){ // Rebuild branch. Snapshot the rendered text BEFORE clearing (#7082 // review should-fix): without it every word re-wraps as `.is-new`, the // whole visible row dips to opacity 0 and fades back (~620ms), and the // wholesale node replacement invites the one-time scroll-anchor bounce - // the #6257 comment in _bindTransparentFadeCleanup warns about. + // the #6257 comment in `_bindTransparentFadeCleanup` warns about. const prevRendered = String(body.textContent || ''); existing.setAttribute('data-stream-fade-text', ''); - // Markdown preservation (Greptile P1): prefer cloning the candidate's own - // `.msg-body` DOM over a plain source-text rebuild. For live rows the - // candidate is the incremental streaming-markdown node, so its body is - // the markdown pipeline's parsed output — links, emphasis, headings, - // lists and code stay rendered instead of the row flattening to literal - // markdown source until settlement. - const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null; + // Non-parser candidates may still carry a parsed-looking body (tests and + // rewind rebuilds). Clone that DOM when present so we do not flatten it + // back to literal source. let resumeCursor = nextText; if(candidateBody && candidateBody !== body && typeof candidateBody.cloneNode === 'function' && @@ -13913,45 +13928,17 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ body.textContent = ''; while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]); _bindTransparentFadeCleanup(body); - // The streaming parser holds a pending tail, so the adopted DOM can lag - // the source text. When the rendered text is a strict prefix of the - // source (plain prose), resume the cursor from it so the held-back - // characters arrive with the next delta instead of being dropped. const renderedNow = String(body.textContent || ''); - if(renderedNow && nextText.startsWith(renderedNow)){ - resumeCursor = renderedNow; - }else{ - // Markdown (Greptile P1 follow-up): rendered text diverges from the - // source, so the rendered-prefix resume above cannot apply — but the - // cloned DOM still lags the source by the parser's held-back tail. A - // full source-length cursor would make the next reconciliation append - // nothing and the pending characters vanish until settlement. The - // parser state is bound on the incremental node's body by - // _smdBindParserIdentity (messages.js), and `pending`/`text` are its - // unrendered source-space buffers (`text` is flushed at the end of - // every parser_write; read defensively anyway). Trim the cursor by - // exactly that held-back length so the cursor matches what the clone - // actually renders and the tail re-appends with the next delta. - const liveParser = candidateBody.__smdParser; - const heldLen = liveParser - ? String(liveParser.pending || '').length + String(liveParser.text || '').length - : 0; - if(heldLen > 0 && heldLen <= nextText.length){ - resumeCursor = nextText.slice(0, nextText.length - heldLen); - } - } + if(renderedNow && nextText.startsWith(renderedNow)) resumeCursor = renderedNow; }else{ body.textContent = ''; _appendTransparentFadeText(body, nextText); } - // messages.js _streamFadeMuteRenderedPrefix idiom (exported on window the - // same way __anchorProseIncrementalNode is): rendered-space compare of the + // messages.js `_streamFadeMuteRenderedPrefix` idiom (exported on window the + // same way `__anchorProseIncrementalNode` is): rendered-space compare of the // pre-rebuild snapshot against the rebuilt body, stripping `.is-new` from // spans inside the common prefix so already-visible words do not replay // their fade — only genuinely-new tail words animate. - const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function') - ? window.__streamFadeMuteRenderedPrefix - : null; if(mute && prevRendered) mute(body, prevRendered); existing.setAttribute('data-stream-fade-text', resumeCursor); }else{ diff --git a/tests/test_live_prose_fade_cursor_source_space.py b/tests/test_live_prose_fade_cursor_source_space.py index a5de2a69..6a9e1bfd 100644 --- a/tests/test_live_prose_fade_cursor_source_space.py +++ b/tests/test_live_prose_fade_cursor_source_space.py @@ -557,19 +557,21 @@ process.stdout.write(JSON.stringify({ assert "**" not in data["finalText"] assert "](" not in data["finalText"] assert data["finalText"] == data["candidateRendered"] - # Rendered text diverges from source here, so the cursor stays in source - # space — but trimmed by the parser's held-back tail (Greptile P1 - # follow-up), so it matches what the adopted DOM actually renders. + # Parser-owned rows must not publish a source-space cursor. The next + # refresh has to adopt the parsed DOM again; pending tails stay on the + # parser until it flushes them. assert data["pendingTail"] == "." - assert data["cursor"] == md_source[: -len(data["pendingTail"])] + assert data["cursor"] is None @pytest.mark.skipif(NODE is None, reason="node not on PATH") -def test_live_prose_rebuild_cursor_excludes_parser_pending_tail(): - """Greptile P1 follow-up: on a cursorless markdown rebuild the adopted DOM - lags the source by the parser's pending tail. The resume cursor must match - the adopted content — not the full source length — so the next - reconciliation re-appends the held-back text before settlement.""" +def test_live_prose_rebuild_leaves_parser_pending_tail_on_parser(): + """Parser pending tails stay on the parser after a cursorless rebuild. + + A source-space resume cursor would make the next refresh append the + held-back bytes as raw text and break later Markdown. The visible row + must keep the adopted parsed DOM until the parser itself flushes. + """ md_source = "Voici **un** [lien](https://example.com) super." script = ( _HARNESS @@ -579,16 +581,12 @@ const smd = await import(process.env.SMD_PATH); const SOURCE = %s; -// Existing row: cursorless, the parser has only seen a prefix of the source. const existing = makeRow(undefined); delete existing.dataset.rawText; const body = existing.querySelector('.msg-body'); const parser = smd.parser(smd.default_renderer(body)); smd.parser_write(parser, SOURCE.slice(0, 8)); -// Candidate: the persistent incremental node, full source written, parser -// still live (not ended) and bound on the body exactly like -// _smdBindParserIdentity does in production. const candidate = makeRow(SOURCE); const candidateBody = candidate.querySelector('.msg-body'); const candidateParser = smd.parser(smd.default_renderer(candidateBody)); @@ -597,17 +595,18 @@ candidateBody.__smdParser = candidateParser; const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || ''); const candidateRendered = candidateBody.textContent; -// First reconciliation: cursorless rebuild adopts the cloned parsed DOM. _refreshTransparentFadeProseRow(existing, candidate, null); const afterRebuildText = existing.querySelector('.msg-body').textContent; const afterRebuildCursor = existing.getAttribute('data-stream-fade-text'); -// Next reconciliation of the SAME live row (no new source text needed — the -// scene re-renders every frame). With a correct cursor this appends the -// held-back tail; with a full source-length cursor it appends nothing and the -// pending text stays missing until settlement. _refreshTransparentFadeProseRow(existing, candidate, null); const afterResumeBody = existing.querySelector('.msg-body'); +const afterResumeStrong = findTag(afterResumeBody, 'STRONG'); +const afterResumeLink = findTag(afterResumeBody, 'A'); + +if(typeof smd.parser_end === 'function') smd.parser_end(candidateParser); +_refreshTransparentFadeProseRow(existing, candidate, null); +const afterEndBody = existing.querySelector('.msg-body'); process.stdout.write(JSON.stringify({ pendingTail, @@ -616,31 +615,148 @@ process.stdout.write(JSON.stringify({ afterRebuildCursor, afterResumeText: afterResumeBody.textContent, afterResumeCursor: existing.getAttribute('data-stream-fade-text'), + hasStrongAfterResume: !!afterResumeStrong, + hasLinkAfterResume: !!afterResumeLink, + literalStarsAfterResume: afterResumeBody.textContent.includes('**'), + afterEndText: afterEndBody.textContent, + literalStarsAfterEnd: afterEndBody.textContent.includes('**'), })); """ % json.dumps(md_source) ) data = _run_node_module(script) - # Precondition: the live parser really holds a non-empty pending tail, and - # the adopted DOM therefore lags the source text. assert data["pendingTail"], "expected the streaming parser to hold a pending tail" assert data["pendingTail"] == "." assert data["afterRebuildText"] == data["candidateRendered"] assert not data["afterRebuildText"].endswith(data["pendingTail"]) + assert data["afterRebuildCursor"] is None + # Same live parser: later refreshes must keep parsed structure. The + # parser may flush its held-back "." into the candidate; that is still + # parser-owned text, not a source-byte append. + assert data["afterResumeCursor"] is None + assert data["hasStrongAfterResume"] is True + assert data["hasLinkAfterResume"] is True + assert data["literalStarsAfterResume"] is False + assert data["afterResumeText"].startswith("Voici un lien super") + assert "**" not in data["afterResumeText"] + assert "](" not in data["afterResumeText"] + # After the parser flushes, the adopted DOM may grow, but still no + # literal markdown syntax. + assert data["literalStarsAfterEnd"] is False + assert "super" in data["afterEndText"] - # The fix under test: the resume cursor matches the adopted content, i.e. - # source minus the parser's held-back tail — not the full source length. - assert data["afterRebuildCursor"] == md_source[: -len(data["pendingTail"])], ( - "resumeCursor must exclude the parser's pending tail; got " - f"{data['afterRebuildCursor']!r}" - ) - # Regression: the next reconciliation re-appends the pending text, so it is - # visible in the live row BEFORE settlement. - assert data["afterResumeText"] == data["candidateRendered"] + data["pendingTail"], ( - "pending text never reached the live row: " - f"{data['afterResumeText']!r}" +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_keeps_parser_authority_across_markdown_close(): + """#7082 re-gate: two reconciliations across incomplete-to-complete Markdown + must keep the parser as the only DOM authority. + + Cloning the parsed body and then appending later source bytes through + ``_appendTransparentFadeText`` leaves literal ``**world**`` in the visible + row while the live parser has already built ``world``. + """ + first_source = "Hello **" + full_source = "Hello **world** done " + script = ( + _HARNESS + + _REBUILD_PRELUDE + + """ +const smd = await import(process.env.SMD_PATH); + +const FIRST = %s; +const FULL = %s; + +function countSubstr(hay, needle){ + if(!needle) return 0; + let n = 0, from = 0; + while(true){ + const i = hay.indexOf(needle, from); + if(i < 0) return n; + n += 1; + from = i + needle.length; + } +} +function serialize(node){ + if(!node) return ''; + if(node.nodeType === 3) return node.textContent; + const tag = String(node.tagName || '').toLowerCase(); + if(!tag || tag === '#fragment' || tag === 'div' && node._classes && node._classes.has('msg-body')){ + return (node.childNodes || []).map(serialize).join(''); + } + const inner = (node.childNodes || []).map(serialize).join(''); + return '<' + tag + '>' + inner + ''; +} + +const existing = makeRow(undefined); +delete existing.dataset.rawText; + +const candidate = makeRow(FIRST); +const candidateBody = candidate.querySelector('.msg-body'); +const parser = smd.parser(smd.default_renderer(candidateBody)); +smd.parser_write(parser, FIRST); +candidateBody.__smdParser = parser; + +_refreshTransparentFadeProseRow(existing, candidate, null); + +smd.parser_write(parser, FULL.slice(FIRST.length)); +candidate.dataset.rawText = FULL; +_refreshTransparentFadeProseRow(existing, candidate, null); + +const liveAfterClose = existing.querySelector('.msg-body'); +const strongAfterClose = findTag(liveAfterClose, 'STRONG'); +const htmlAfterClose = serialize(liveAfterClose); +const textAfterClose = liveAfterClose.textContent; +const candidateHtmlAfterClose = serialize(candidateBody); + +if(typeof smd.parser_end === 'function') smd.parser_end(parser); +_refreshTransparentFadeProseRow(existing, candidate, null); +const liveAfterEnd = existing.querySelector('.msg-body'); +const strongAfterEnd = findTag(liveAfterEnd, 'STRONG'); + +process.stdout.write(JSON.stringify({ + htmlAfterClose, + textAfterClose, + candidateHtmlAfterClose, + candidateTextAfterClose: candidateBody.textContent, + hasStrongAfterClose: !!strongAfterClose, + strongTextAfterClose: strongAfterClose ? strongAfterClose.textContent : null, + helloCountAfterClose: countSubstr(textAfterClose, 'Hello'), + worldCountAfterClose: countSubstr(textAfterClose, 'world'), + doneCountAfterClose: countSubstr(textAfterClose, 'done'), + literalStarsAfterClose: textAfterClose.includes('**'), + htmlAfterEnd: serialize(liveAfterEnd), + textAfterEnd: liveAfterEnd.textContent, + hasStrongAfterEnd: !!strongAfterEnd, + strongTextAfterEnd: strongAfterEnd ? strongAfterEnd.textContent : null, + literalStarsAfterEnd: liveAfterEnd.textContent.includes('**'), + helloCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'Hello'), + worldCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'world'), + doneCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'done'), +})); +""" + % (json.dumps(first_source), json.dumps(full_source)) ) - assert data["afterResumeText"].endswith("super.") - assert data["afterResumeCursor"] == md_source + data = _run_node_module(script) + + assert data["hasStrongAfterClose"] is True, ( + "visible row lost parser emphasis after the second reconciliation: " + f"html={data['htmlAfterClose']!r} text={data['textAfterClose']!r}" + ) + assert data["strongTextAfterClose"] == "world" + assert data["literalStarsAfterClose"] is False, ( + "literal ** leaked into the live row: " + f"{data['textAfterClose']!r} html={data['htmlAfterClose']!r}" + ) + assert data["helloCountAfterClose"] == 1 + assert data["worldCountAfterClose"] == 1 + assert data["doneCountAfterClose"] == 1 + assert "world" in data["textAfterClose"] + assert "done" in data["textAfterClose"] + + assert data["hasStrongAfterEnd"] is True + assert data["strongTextAfterEnd"] == "world" + assert data["literalStarsAfterEnd"] is False + assert data["helloCountAfterEnd"] == 1 + assert data["worldCountAfterEnd"] == 1 + assert data["doneCountAfterEnd"] == 1 From 69e4fa3b98a316589265b7d509bf9a6d69efcd02 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Tue, 18 Aug 2026 22:32:16 +0000 Subject: [PATCH 5/6] fix(stream): adopt parser-owned fade row instead of recloning it After rewind/cache-miss the keyed visible row stayed distinct from the persistent parser candidate, so every later growth frame deep-cloned the parser DOM into the old row. That cut off the previous tail word's ~620ms fade and broke word-node identity. Promote the parser-owned candidate into the existing row's DOM position once, mute the already-rendered prefix on the real parser body, and return the adopted node so later keyed renders hit existing === node. Regression: two post-transition growth frames keep the first new tail span as the same .is-new node until an explicit animationend. --- static/ui.js | 54 ++-- ...est_live_prose_fade_cursor_source_space.py | 263 ++++++++++++++++-- 2 files changed, 275 insertions(+), 42 deletions(-) diff --git a/static/ui.js b/static/ui.js index aabb4c7b..76ec6110 100644 --- a/static/ui.js +++ b/static/ui.js @@ -13859,6 +13859,8 @@ function _appendTransparentFadeText(body, text){ } function _refreshTransparentFadeProseRow(existing, node, preservedState){ + if(!existing || !node) return node || existing; + if(existing === node) return existing; let body = existing.querySelector ? existing.querySelector('.msg-body') : null; const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || '')); // Resume strictly from the source-space cursor. `body.textContent` is NOT a @@ -13870,6 +13872,37 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ // guessing a delta. const hasCursor = !!(existing.getAttribute && existing.getAttribute('data-stream-fade-text') !== null); const currentText = hasCursor ? String(existing.getAttribute('data-stream-fade-text') || '') : ''; + const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null; + const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser); + const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function') + ? window.__streamFadeMuteRenderedPrefix + : null; + if(parserOwned){ + // Promote the actual parser-owned candidate into the keyed row's DOM + // position once. Cloning its children into the old visible row on every + // later growth frame cuts off the previous tail word's ~620ms fade + // (`.is-new` is stripped from the replacement) and breaks word-node + // identity used for scroll-anchor stability. After this swap, later + // keyed renders hit `_refreshTransparentLiveRow(existing === node)` and + // keep growing the same parser target. Pending and MEDIA tails stay on + // the parser until it flushes them. + const prevRendered = String((body && body.textContent) || ''); + if(candidateBody.classList) candidateBody.classList.add('stream-fade-active'); + _bindTransparentFadeCleanup(candidateBody); + if(mute && prevRendered) mute(candidateBody, prevRendered); + if(node.removeAttribute) node.removeAttribute('data-stream-fade-text'); + const parent = existing.parentNode || existing.parentElement || null; + if(parent && existing.parentNode === parent){ + if(typeof parent.replaceChild === 'function'){ + parent.replaceChild(node, existing); + }else if(typeof parent.insertBefore === 'function'){ + parent.insertBefore(node, existing); + if(typeof existing.remove === 'function') existing.remove(); + } + } + _rehydrateTransparentLiveRow(node, existing, preservedState); + return node; + } const pairs = _transparentLiveRowAttributePairs(node); const kept = Object.create(null); for(const pair of pairs){ @@ -13890,26 +13923,7 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){ existing.appendChild(body); } if(body.classList) body.classList.add('stream-fade-active'); - const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null; - const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser); - const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function') - ? window.__streamFadeMuteRenderedPrefix - : null; - if(parserOwned && typeof candidateBody.cloneNode === 'function'){ - // One authority after the first parser-owned rebuild: keep adopting the - // live parsed DOM. Cloning once and then appending later source bytes - // through `_appendTransparentFadeText` turns `Hello **` / `Hello **world**` - // into literal `**world**` while the candidate already has - // `world`. Pending and MEDIA tails stay on the parser - // until it flushes them into this candidate. - const prevRendered = String(body.textContent || ''); - const clone = candidateBody.cloneNode(true); - body.textContent = ''; - while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]); - _bindTransparentFadeCleanup(body); - if(mute && prevRendered) mute(body, prevRendered); - if(existing.removeAttribute) existing.removeAttribute('data-stream-fade-text'); - }else if(!hasCursor || !nextText.startsWith(currentText)){ + if(!hasCursor || !nextText.startsWith(currentText)){ // Rebuild branch. Snapshot the rendered text BEFORE clearing (#7082 // review should-fix): without it every word re-wraps as `.is-new`, the // whole visible row dips to opacity 0 and fades back (~620ms), and the diff --git a/tests/test_live_prose_fade_cursor_source_space.py b/tests/test_live_prose_fade_cursor_source_space.py index 6a9e1bfd..41d77967 100644 --- a/tests/test_live_prose_fade_cursor_source_space.py +++ b/tests/test_live_prose_fade_cursor_source_space.py @@ -110,6 +110,7 @@ class FakeElement { this.type = ''; this.disabled = false; this._classes = new Set(); + this._listeners = Object.create(null); const self = this; this.classList = { add(...n){ n.forEach(x=>self._classes.add(x)); }, @@ -139,11 +140,46 @@ class FakeElement { if(n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); } n.parentNode = this; this.childNodes.push(n); return n; } + insertBefore(n, ref){ + if(n && n._isFragment){ n.childNodes.slice().forEach(c=>this.insertBefore(c, ref)); return n; } + if(n && n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); } + if(n) n.parentNode = this; + if(!ref){ this.childNodes.push(n); return n; } + const i = this.childNodes.indexOf(ref); + if(i < 0) this.childNodes.push(n); + else this.childNodes.splice(i, 0, n); + return n; + } + replaceChild(n, old){ + const i = this.childNodes.indexOf(old); + if(i < 0) return old; + if(n && n.parentNode){ const j = n.parentNode.childNodes.indexOf(n); if(j >= 0) n.parentNode.childNodes.splice(j, 1); } + if(old) old.parentNode = null; + if(n) n.parentNode = this; + this.childNodes[i] = n; + return old; + } + remove(){ + if(!this.parentNode) return; + const i = this.parentNode.childNodes.indexOf(this); + if(i >= 0) this.parentNode.childNodes.splice(i, 1); + this.parentNode = null; + } setAttribute(name, value){ this.attributes[String(name)] = String(value ?? ''); } getAttribute(name){ const k = String(name); return k in this.attributes ? this.attributes[k] : null; } removeAttribute(name){ delete this.attributes[String(name)]; } getAttributeNames(){ return Object.keys(this.attributes); } - addEventListener(){} + addEventListener(type, fn){ + const key = String(type || ''); + if(!this._listeners[key]) this._listeners[key] = []; + this._listeners[key].push(fn); + } + dispatchEvent(event){ + const type = event && event.type ? String(event.type) : ''; + const list = this._listeners[type] || []; + for(const fn of list) fn(event); + return true; + } cloneNode(deep){ const copy = new FakeElement(this.tagName); copy._classes = new Set(this._classes); @@ -153,11 +189,19 @@ class FakeElement { return copy; } querySelector(sel){ - const want = String(sel).split(',')[0].trim().replace(/^\./, ''); + const options = String(sel || '').split(',').map(s => s.trim()).filter(Boolean); + const matches = (el, option)=>{ + const classes = []; + const re = /\.([A-Za-z0-9_-]+)/g; + let m; + while((m = re.exec(option))) classes.push(m[1]); + if(!classes.length) return false; + return classes.every(cls => el._classes && el._classes.has(cls)); + }; const walk = (node)=>{ for(const c of node.childNodes){ if(c.nodeType !== 1) continue; - if(c._classes.has(want)) return c; + if(options.some(opt => matches(c, opt))) return c; const hit = walk(c); if(hit) return hit; } @@ -371,6 +415,7 @@ const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8'); extractFunc('_transparentLiveRowInteractiveState'), extractFunc('_rehydrateTransparentLiveRow'), extractFunc('_refreshTransparentFadeProseRow'), + extractFunc('_refreshTransparentLiveRow'), ].join('\\n')); (0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix')); globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix; @@ -390,13 +435,14 @@ function makeRow(rawText){ return row; } function collectFadeSpans(root){ + return collectFadeSpanNodes(root).map(c=>({ text: c.textContent, isNew: c._classes.has('is-new') })); +} +function collectFadeSpanNodes(root){ const out = []; const walk = (node)=>{ for(const c of node.childNodes || []){ if(c.nodeType !== 1) continue; - if(c._classes && c._classes.has('stream-fade-word')){ - out.push({ text: c.textContent, isNew: c._classes.has('is-new') }); - } + if(c._classes && c._classes.has('stream-fade-word')) out.push(c); walk(c); } }; @@ -525,9 +571,9 @@ candidateBody.__smdParser = candidateParser; const candidateRendered = candidateBody.textContent; const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || ''); -_refreshTransparentFadeProseRow(existing, candidate, null); +const visible = _refreshTransparentFadeProseRow(existing, candidate, null); -const refreshedBody = existing.querySelector('.msg-body'); +const refreshedBody = visible.querySelector('.msg-body'); const link = findTag(refreshedBody, 'A'); const strong = findTag(refreshedBody, 'STRONG'); process.stdout.write(JSON.stringify({ @@ -540,7 +586,9 @@ process.stdout.write(JSON.stringify({ linkHref: link ? link.getAttribute('href') : null, hasStrong: !!strong, strongText: strong ? strong.textContent : null, - cursor: existing.getAttribute('data-stream-fade-text'), + cursor: visible.getAttribute('data-stream-fade-text'), + adoptedParserRow: visible === candidate, + sameParserBody: refreshedBody === candidateBody, })); """ % json.dumps(md_source) @@ -562,6 +610,8 @@ process.stdout.write(JSON.stringify({ # parser until it flushes them. assert data["pendingTail"] == "." assert data["cursor"] is None + assert data["adoptedParserRow"] is True + assert data["sameParserBody"] is True @pytest.mark.skipif(NODE is None, reason="node not on PATH") @@ -595,18 +645,18 @@ candidateBody.__smdParser = candidateParser; const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || ''); const candidateRendered = candidateBody.textContent; -_refreshTransparentFadeProseRow(existing, candidate, null); -const afterRebuildText = existing.querySelector('.msg-body').textContent; -const afterRebuildCursor = existing.getAttribute('data-stream-fade-text'); +const visible = _refreshTransparentFadeProseRow(existing, candidate, null); +const afterRebuildText = visible.querySelector('.msg-body').textContent; +const afterRebuildCursor = visible.getAttribute('data-stream-fade-text'); -_refreshTransparentFadeProseRow(existing, candidate, null); -const afterResumeBody = existing.querySelector('.msg-body'); +const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null); +const afterResumeBody = sameVisible.querySelector('.msg-body'); const afterResumeStrong = findTag(afterResumeBody, 'STRONG'); const afterResumeLink = findTag(afterResumeBody, 'A'); if(typeof smd.parser_end === 'function') smd.parser_end(candidateParser); -_refreshTransparentFadeProseRow(existing, candidate, null); -const afterEndBody = existing.querySelector('.msg-body'); +const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null); +const afterEndBody = settledVisible.querySelector('.msg-body'); process.stdout.write(JSON.stringify({ pendingTail, @@ -614,12 +664,16 @@ process.stdout.write(JSON.stringify({ afterRebuildText, afterRebuildCursor, afterResumeText: afterResumeBody.textContent, - afterResumeCursor: existing.getAttribute('data-stream-fade-text'), + afterResumeCursor: sameVisible.getAttribute('data-stream-fade-text'), hasStrongAfterResume: !!afterResumeStrong, hasLinkAfterResume: !!afterResumeLink, literalStarsAfterResume: afterResumeBody.textContent.includes('**'), afterEndText: afterEndBody.textContent, literalStarsAfterEnd: afterEndBody.textContent.includes('**'), + adoptedParserRow: visible === candidate, + laterRefreshSameRow: sameVisible === candidate, + settledSameRow: settledVisible === candidate, + sameParserBody: afterResumeBody === candidateBody, })); """ % json.dumps(md_source) @@ -634,6 +688,10 @@ process.stdout.write(JSON.stringify({ # Same live parser: later refreshes must keep parsed structure. The # parser may flush its held-back "." into the candidate; that is still # parser-owned text, not a source-byte append. + assert data["adoptedParserRow"] is True + assert data["laterRefreshSameRow"] is True + assert data["settledSameRow"] is True + assert data["sameParserBody"] is True assert data["afterResumeCursor"] is None assert data["hasStrongAfterResume"] is True assert data["hasLinkAfterResume"] is True @@ -697,21 +755,21 @@ const parser = smd.parser(smd.default_renderer(candidateBody)); smd.parser_write(parser, FIRST); candidateBody.__smdParser = parser; -_refreshTransparentFadeProseRow(existing, candidate, null); +const visible = _refreshTransparentFadeProseRow(existing, candidate, null); smd.parser_write(parser, FULL.slice(FIRST.length)); candidate.dataset.rawText = FULL; -_refreshTransparentFadeProseRow(existing, candidate, null); +const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null); -const liveAfterClose = existing.querySelector('.msg-body'); +const liveAfterClose = sameVisible.querySelector('.msg-body'); const strongAfterClose = findTag(liveAfterClose, 'STRONG'); const htmlAfterClose = serialize(liveAfterClose); const textAfterClose = liveAfterClose.textContent; const candidateHtmlAfterClose = serialize(candidateBody); if(typeof smd.parser_end === 'function') smd.parser_end(parser); -_refreshTransparentFadeProseRow(existing, candidate, null); -const liveAfterEnd = existing.querySelector('.msg-body'); +const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null); +const liveAfterEnd = settledVisible.querySelector('.msg-body'); const strongAfterEnd = findTag(liveAfterEnd, 'STRONG'); process.stdout.write(JSON.stringify({ @@ -733,6 +791,10 @@ process.stdout.write(JSON.stringify({ helloCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'Hello'), worldCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'world'), doneCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'done'), + adoptedParserRow: visible === candidate, + laterRefreshSameRow: sameVisible === candidate, + settledSameRow: settledVisible === candidate, + sameParserBody: liveAfterClose === candidateBody, })); """ % (json.dumps(first_source), json.dumps(full_source)) @@ -760,3 +822,160 @@ process.stdout.write(JSON.stringify({ assert data["helloCountAfterEnd"] == 1 assert data["worldCountAfterEnd"] == 1 assert data["doneCountAfterEnd"] == 1 + assert data["adoptedParserRow"] is True + assert data["laterRefreshSameRow"] is True + assert data["settledSameRow"] is True + assert data["sameParserBody"] is True + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_live_prose_post_rewind_keeps_tail_word_identity_across_two_growths(): + """#7082 must-fix: after the separate-owner rewind transition, two later + growth frames must keep the first genuinely-new tail span as the same + node with ``.is-new`` until an explicit ``animationend``. + """ + prefix_source = "Hello there" + first_growth = "Hello there world" + second_growth = "Hello there world more" + script = ( + _HARNESS + + _REBUILD_PRELUDE + + """ +const smd = await import(process.env.SMD_PATH); + +const PREFIX = %s; +const FIRST = %s; +const SECOND = %s; + +function wrapWords(parent, text){ + const words = String(text || '').split(' '); + words.forEach((word, i)=>{ + const span = new FakeElement('span'); + span.className = 'stream-fade-word is-new'; + span.textContent = word; + parent.appendChild(span); + if(i < words.length - 1) parent.appendChild(new TextNode(' ')); + }); +} + +const host = new FakeElement('div'); +const existing = makeRow(undefined); +delete existing.dataset.rawText; +const staleBody = existing.querySelector('.msg-body'); +wrapWords(staleBody, PREFIX); +host.appendChild(existing); + +const candidate = makeRow(PREFIX); +const candidateBody = candidate.querySelector('.msg-body'); +const parser = smd.parser(smd.default_renderer(candidateBody)); +smd.parser_write(parser, PREFIX); +candidateBody.__smdParser = parser; +const pendingAtAdopt = String(parser.pending || '') + String(parser.text || ''); +// Keep the live parser identity, then rebuild fade spans so the mute + later +// growth can be observed as node identity. Do not parser_write after this: +// the default renderer would replace the word nodes we need to pin. +candidateBody.textContent = ''; +const p = new FakeElement('p'); +const strong = new FakeElement('strong'); +const hello = new FakeElement('span'); +hello.className = 'stream-fade-word is-new'; +hello.textContent = 'Hello'; +const there = new FakeElement('span'); +there.className = 'stream-fade-word is-new'; +there.textContent = 'there'; +strong.appendChild(there); +p.appendChild(hello); +p.appendChild(new TextNode(' ')); +p.appendChild(strong); +candidateBody.appendChild(p); + +const visible = _refreshTransparentFadeProseRow(existing, candidate, null); +const afterAdoptNew = collectFadeSpanNodes(visible).filter(span => span._classes.has('is-new')); + +candidate.dataset.rawText = FIRST; +const world = new FakeElement('span'); +world.className = 'stream-fade-word is-new'; +world.textContent = 'world'; +p.appendChild(new TextNode(' ')); +p.appendChild(world); +const afterFirstGrowth = _refreshTransparentLiveRow(visible, candidate, null); +const firstNewWasAnimated = world._classes.has('is-new'); +const firstNewAfterFirstGrowth = world; + +candidate.dataset.rawText = SECOND; +const more = new FakeElement('span'); +more.className = 'stream-fade-word is-new'; +more.textContent = 'more'; +p.appendChild(new TextNode(' ')); +p.appendChild(more); +const afterSecondGrowth = _refreshTransparentLiveRow(afterFirstGrowth, candidate, null); +const afterSecondSpans = collectFadeSpanNodes(afterSecondGrowth); +const afterSecondNew = afterSecondSpans.filter(span => span._classes.has('is-new')); +const secondNewSameIdentity = world === firstNewAfterFirstGrowth && afterSecondSpans.includes(world); +const secondNewIsNew = world._classes.has('is-new'); + +visible.querySelector('.msg-body').dispatchEvent({ type: 'animationend', target: world }); +const afterCleanupNew = collectFadeSpanNodes(afterSecondGrowth).filter(span => span._classes.has('is-new')); + +if(typeof smd.parser_end === 'function') smd.parser_end(parser); +const settled = _refreshTransparentLiveRow(afterSecondGrowth, candidate, null); +const settledBody = settled.querySelector('.msg-body'); + +process.stdout.write(JSON.stringify({ + adoptedParserRow: visible === candidate, + firstGrowthSameRow: afterFirstGrowth === candidate, + secondGrowthSameRow: afterSecondGrowth === candidate, + sameParserBody: afterSecondGrowth.querySelector('.msg-body') === candidateBody, + afterAdoptNewTexts: afterAdoptNew.map(span => span.textContent), + firstNewText: firstNewAfterFirstGrowth.textContent, + firstNewWasAnimated, + firstNewStillConnected: !!(world.parentNode), + secondNewSameIdentity, + secondNewIsNew, + afterSecondNewTexts: afterSecondNew.map(span => span.textContent), + afterCleanupNewTexts: afterCleanupNew.map(span => span.textContent), + hasStrong: !!findTag(afterSecondGrowth, 'STRONG'), + strongText: findTag(afterSecondGrowth, 'STRONG') ? findTag(afterSecondGrowth, 'STRONG').textContent : null, + literalStars: afterSecondGrowth.textContent.includes('**'), + pendingAtAdopt, + settledSameRow: settled === candidate, + afterSecondText: afterSecondGrowth.querySelector('.msg-body').textContent, + afterEndText: settledBody.textContent, + cursorAfterAdopt: visible.getAttribute('data-stream-fade-text'), + cursorAfterSecond: afterSecondGrowth.getAttribute('data-stream-fade-text'), + oldRowDetached: existing.parentNode === null, + adoptedRowParented: visible.parentNode === host, + helloCount: (settledBody.textContent.match(/Hello/g) || []).length, + worldCount: (settledBody.textContent.match(/world/g) || []).length, + moreCount: (settledBody.textContent.match(/more/g) || []).length, +})); +""" + % (json.dumps(prefix_source), json.dumps(first_growth), json.dumps(second_growth)) + ) + data = _run_node_module(script) + + assert data["adoptedParserRow"] is True + assert data["firstGrowthSameRow"] is True + assert data["secondGrowthSameRow"] is True + assert data["sameParserBody"] is True + assert data["afterAdoptNewTexts"] == [] + assert data["firstNewText"] == "world" + assert data["firstNewWasAnimated"] is True + assert data["firstNewStillConnected"] is True + assert data["secondNewSameIdentity"] is True + assert data["secondNewIsNew"] is True + assert data["afterSecondNewTexts"] == ["world", "more"] + assert data["afterCleanupNewTexts"] == ["more"] + assert data["hasStrong"] is True + assert data["strongText"] == "there" + assert data["literalStars"] is False + assert data["settledSameRow"] is True + assert data["afterSecondText"] == second_growth + assert data["afterEndText"] == second_growth + assert data["cursorAfterAdopt"] is None + assert data["cursorAfterSecond"] is None + assert data["oldRowDetached"] is True + assert data["adoptedRowParented"] is True + assert data["helloCount"] == 1 + assert data["worldCount"] == 1 + assert data["moreCount"] == 1 From b52f5b1f0ea834d1eb3b31f60b89567c090c5623 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 8 Sep 2026 21:48:18 +0000 Subject: [PATCH 6/6] changelog: #7082 stop tearing live progress words (@ruizanthony) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b4c41d..dafd2f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Live progress text in Transparent Stream no longer tears in the middle of a word.** During a streaming turn, a live progress row could split a word across a block boundary and render the tail on its own line (e.g. `Ces deu` / `x fichiers passent.`). The streamed text was always intact — it was purely a client-side render artifact in the live fade/reconcile path. The renderer now adopts the parser-owned DOM once after a parser-owner replacement (keeping the per-token fast path for normal ticks) and handles the source-space cursor at block boundaries, so live prose stays whole with no lost, duplicated, or reordered text. Verified with the crown-jewel stream gate (no flicker, no alternating, no prose reversals across transparent/worklog/hidden modes). Thanks @ruizanthony. (#7082) + - **A slash-style model ID picked under a session provider that differs from the profile default now routes to the right provider instead of 404ing.** When you selected a model whose ID contains a `/` (e.g. a Nous portal row `upstage/solar-pro4:free`) while the session's provider differed from the profile default, `model_with_provider_context()` dropped the provider hint, so the request went to the *default* provider's base URL (e.g. `api.x.ai` under an xAI-OAuth default) and 404'd. The resolver now emits an explicit `@provider:model` hint when the session provider is a known static/portal provider or a `custom:` that resolves to a unique `custom_providers[]` entry, while still keeping the bare ID for unknown/ambiguous slugs so existing custom/proxy base-URL routing stays in charge. Ambiguous custom slugs fail closed to a persisted error rather than crashing the worker. Thanks @Manny7717. (#7356, closes #7333) - **Editing and resubmitting a message can no longer truncate most of a long session's history if you click "Send edit" more than once.** `submitEdit` was re-entrant: its only guard was `S.busy`, which the send path doesn't set until after two multi-second awaits (`_ensureAllMessagesLoaded` on a long session, then the truncate round-trip). On a laggy instance that left a 10s+ window in which a second click started another full truncate — and because the first call sets `_oldestIdx = 0`, the second call recomputed `keep_count` from a still-window-relative index, producing a far smaller keep count that could delete most of a 2000-message session. A `_submitEditInFlight` guard is now acquired synchronously before the awaits and released in a `finally` (so an early return or throw can't wedge editing off), while the absolute keep count is still captured up front so a single legitimate submit is unaffected. The Enter-to-submit path clicks the same button, so it's covered too. Thanks @alanjds. (#7277)