From c15cb848676ed31701aa6b9b38ce8ddb550c21e8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 21:19:41 -0400 Subject: [PATCH 01/11] fix(#5493): restore transparent stream prose fade --- static/messages.js | 9 +++++++-- tests/test_smooth_text_fade.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 1f3114b9..02f23666 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3637,21 +3637,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _anchorProseIncrementalNode(key, text){ if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null; const value=String(text||''); + const fade=typeof _shouldUseStreamFade==='function'&&_shouldUseStreamFade(); try{ let st=_anchorProseSmdCache.get(key); // Self-heal desyncs (edit/sanitize made the text no longer a pure append): // rebuild the parser+node from scratch, mirroring _smdWrite's guard. if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null; + if(st && st.fade!==fade) st=null; if(!st){ const node=document.createElement('div'); node.className='assistant-segment'; node.setAttribute('data-anchor-scene-prose','1'); const body=document.createElement('div'); body.className='msg-body'; + if(body.classList) body.classList.toggle('stream-fade-active',fade); node.appendChild(body); - const baseRenderer=_safeSmdRenderer(body); + const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body); const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer); - st={node,parser:window.smd.parser(renderer),writtenText:''}; + st={node,parser:window.smd.parser(renderer),writtenText:'',fade}; _anchorProseSmdCache.set(key,st); // Bound memory across turns: keys embed the stream id, so stale entries // from finished streams age out here. @@ -3660,6 +3663,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(oldest!==key) _anchorProseSmdCache.delete(oldest); } } + const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body'); + if(body&&body.classList) body.classList.toggle('stream-fade-active',fade); const delta=value.slice(st.writtenText.length); if(delta){ window.smd.parser_write(st.parser,delta); diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 5a1cd78a..3e837cac 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -191,6 +191,21 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): assert "_wrapStreamingFadeWords" not in MESSAGES_JS +def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): + anchor_block = function_block(MESSAGES_JS, "_anchorProseIncrementalNode") + assert_contains_all( + anchor_block, + [ + "const fade=typeof _shouldUseStreamFade==='function'&&_shouldUseStreamFade()", + "if(st && st.fade!==fade) st=null", + "if(body.classList) body.classList.toggle('stream-fade-active',fade)", + "const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body)", + "st={node,parser:window.smd.parser(renderer),writtenText:'',fade}", + "const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body')", + ], + ) + + def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone") assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=900" in MESSAGES_JS From fbebd0bd309d7ae6347f87af683940a89e8376a4 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:07:10 -0400 Subject: [PATCH 02/11] Preserve transparent fade DOM during live row refresh --- static/ui.js | 12 +++++++ ...ssue5367_transparent_live_row_reconcile.py | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/static/ui.js b/static/ui.js index 170eb14e..b6608aab 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11188,6 +11188,18 @@ function _refreshTransparentLiveRow(existing, node){ if(!existing || !node || !existing.getAttribute) return node; if(existing===node) return existing; const preservedState = _transparentLiveRowInteractiveState(existing); + const candidateIsFadeProse = node.getAttribute('data-anchor-row-role') === 'prose' && + node.querySelector && + !!node.querySelector('.msg-body.stream-fade-active,.stream-fade-word'); + if(candidateIsFadeProse){ + const parent = existing.parentNode; + if(parent){ + parent.insertBefore(node, existing); + existing.remove(); + } + _rehydrateTransparentLiveRow(node, node, preservedState); + return node; + } const pairs = _transparentLiveRowAttributePairs(node); const kept = Object.create(null); for(const pair of pairs){ diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 944f547c..d18306d9 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -378,6 +378,32 @@ const keylessRowsAfterThird = turn.querySelectorAll('.transparent-event-row[data _renderLiveAnchorActivitySceneTransparent('stream-1', fourthScene, {{ sessionId:'session-1' }}); const keylessRowsAfterFourth = turn.querySelectorAll('.transparent-event-row[data-anchor-row-id=\"\"]'); +const fadeParent = new FakeElement('div'); +const fadeExisting = new FakeElement('div'); +fadeExisting.className = 'assistant-segment transparent-event-row'; +fadeExisting.setAttribute('data-anchor-row-role', 'prose'); +fadeExisting.setAttribute('data-anchor-row-id', 'fade-row'); +fadeExisting.setAttribute('data-anchor-source-event-type', 'process_prose'); +const staleBody = new FakeElement('div'); +staleBody.className = 'msg-body'; +staleBody.textContent = 'old flat text'; +fadeExisting.appendChild(staleBody); +fadeParent.appendChild(fadeExisting); + +const fadeCandidate = new FakeElement('div'); +fadeCandidate.className = 'assistant-segment transparent-event-row'; +fadeCandidate.setAttribute('data-anchor-row-role', 'prose'); +fadeCandidate.setAttribute('data-anchor-row-id', 'fade-row'); +fadeCandidate.setAttribute('data-anchor-source-event-type', 'process_prose'); +const fadeBody = new FakeElement('div'); +fadeBody.className = 'msg-body stream-fade-active'; +const fadeSpan = new FakeElement('span'); +fadeSpan.className = 'stream-fade-word is-new'; +fadeSpan.textContent = 'new'; +fadeBody.appendChild(fadeSpan); +fadeCandidate.appendChild(fadeBody); +const fadeRefresh = _refreshTransparentLiveRow(fadeExisting, fadeCandidate); + process.stdout.write(JSON.stringify({{ firstRender, secondRender, @@ -402,6 +428,11 @@ process.stdout.write(JSON.stringify({{ keylessAfterFourth: keylessRowsAfterFourth.length, keylessTextsAfterFourth: keylessRowsAfterFourth.map((child) => child.textContent), totalRowsAfterFourth: turn.children.filter((child) => child.classList.contains('transparent-event-row')).length, + fadeReplacedWithCandidate: fadeRefresh === fadeCandidate, + fadeExistingRemoved: fadeExisting.parentNode === null, + fadeCandidateParented: fadeCandidate.parentNode === fadeParent, + fadeSpanPreserved: !!fadeParent.querySelector('.stream-fade-word.is-new'), + fadeBodyActive: !!fadeParent.querySelector('.msg-body.stream-fade-active'), }})); """ script = script.replace("{{", "{").replace("}}", "}") @@ -438,6 +469,11 @@ process.stdout.write(JSON.stringify({{ assert data["keylessAfterFourth"] == 1 assert data["keylessTextsAfterFourth"] == ["keyless row second"] assert data["totalRowsAfterFourth"] == 1 + assert data["fadeReplacedWithCandidate"] is True + assert data["fadeExistingRemoved"] is True + assert data["fadeCandidateParented"] is True + assert data["fadeSpanPreserved"] is True + assert data["fadeBodyActive"] is True @pytest.mark.skipif(NODE is None, reason="node not on PATH") From af2f534f216a9b22d2e477cee218bbddcaa6458c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:25:45 -0400 Subject: [PATCH 03/11] Append fade spans instead of replaying streamed prose --- static/messages.js | 63 +++++++++++++++++++++++++----- tests/test_smooth_text_fade.py | 70 ++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/static/messages.js b/static/messages.js index 02f23666..f0ec6b0d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2547,6 +2547,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=200; + let _streamFadeDomText=''; let _streamFadeReduceMotionMql=null; let _streamFadeReduceMotion=false; let _streamFadeReduceMotionOnChange=null; @@ -4091,6 +4092,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeVisibleWords=0; _streamFadeHoldUntilMs=0; _streamFadeCurrentMs=_STREAM_FADE_MS; + _streamFadeDomText=''; } function _cancelAnimationFramePendingStreamRender(){ if(_pendingRafHandle===null) return; @@ -4230,6 +4232,43 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const m=String(text||'').match(/\S+/g); return m?m.length:0; } + function _streamFadeAppendText(el, text){ + if(!el) return; + const value=String(text||''); + if(!value) return; + const reduceMotion=_streamFadeReduceMotionEnabled(); + const frag=document.createDocumentFragment(); + const wordRe=/(\S+)(\s*)/g; + const appendStartedAt=performance.now(); + let last=0, match, changed=false; + _streamFadeAppendOffset=0; + while((match=wordRe.exec(value))){ + if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index))); + if(reduceMotion){ + frag.appendChild(document.createTextNode(match[1])); + }else{ + const span=document.createElement('span'); + span.className='stream-fade-word is-new'; + const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS; + const delayMs=_streamFadeAppendOffset*_STREAM_FADE_STAGGER_MS; + span.style.animationDelay=delayMs+'ms'; + if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms'); + span.textContent=match[1]; + frag.appendChild(span); + _streamFadeAppendOffset+=1; + _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+delayMs+fadeMs); + } + if(match[2]) frag.appendChild(document.createTextNode(match[2])); + last=match.index+match[0].length; + changed=true; + } + if(!changed){ + frag.appendChild(document.createTextNode(value)); + }else if(last=0) return 90; const trimmed=String(text||'').trimEnd(); @@ -4333,17 +4372,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const next=_streamFadeNextText(displayText); if(!next.changed) return next.caughtUp; assistantBody.classList.add('stream-fade-active'); - if(!_smdParser&&window.smd){ - if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;} - _smdNewParser(assistantBody,true); - } + _streamFadeBindCleanup(assistantBody); if(_smdParser){ - _streamFadeAppendOffset=0; - _smdWrite(next.text,true); - }else{ - assistantBody.innerHTML=renderMd ? renderMd(next.text||'') : esc(next.text||''); - _sanitizeSmdLinks(assistantBody); + _smdEndParser(); + assistantBody.textContent=''; + _streamFadeDomText=''; } + _smdReconnect=false; + if(!_streamFadeDomText&&assistantBody.textContent){ + assistantBody.textContent=''; + } + if(!String(next.text||'').startsWith(_streamFadeDomText)){ + assistantBody.textContent=''; + _streamFadeDomText=''; + } + const delta=String(next.text||'').slice(_streamFadeDomText.length); + _streamFadeAppendText(assistantBody,delta); + _streamFadeDomText=String(next.text||''); return next.caughtUp; } function _streamFadeCurrentDisplayText(){ diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 3e837cac..f203fe58 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -86,6 +86,7 @@ let _streamFadeAppendOffset=0; let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=200; +let _streamFadeDomText=''; const _STREAM_FADE_MS=200; const _STREAM_FADE_MAX_MS=350; const _STREAM_FADE_STAGGER_MS=16; @@ -166,12 +167,24 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): [ "_streamFadeNextText(displayText)", "if(!next.changed) return next.caughtUp", - "_smdNewParser(assistantBody,true)", - "_smdWrite(next.text,true)", + "_streamFadeBindCleanup(assistantBody)", + "_streamFadeAppendText(assistantBody,delta)", + "_streamFadeDomText=String(next.text||'')", "stream-fade-active", ], ) - assert "renderMd ? renderMd(next.text||'')" in render_block + assert "_smdWrite(next.text,true)" not in render_block + assert "innerHTML" not in render_block + append_block = function_block(MESSAGES_JS, "_streamFadeAppendText") + assert_contains_all( + append_block, + [ + "document.createDocumentFragment()", + "span.className='stream-fade-word is-new'", + "el.appendChild(frag)", + "_streamFadeLatestAnimationEndAt", + ], + ) assert_contains_all( renderer_block, [ @@ -191,6 +204,57 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): assert "_wrapStreamingFadeWords" not in MESSAGES_JS +def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): + script = ( + function_block(MESSAGES_JS, "_streamFadeAppendText") + + r""" +const _STREAM_FADE_MS=200; +const _STREAM_FADE_STAGGER_MS=16; +let _streamFadeAppendOffset=0; +let _streamFadeLatestAnimationEndAt=0; +let _streamFadeCurrentMs=200; +const performance={_t:0,now(){return this._t;}}; +function _streamFadeReduceMotionEnabled(){ return false; } +class FakeNode{ + constructor(type,text=''){ + this.type=type; + this.children=[]; + this.className=''; + this.textContent=text; + this.style={values:{},setProperty:(name,value)=>{this.style.values[name]=value;}}; + } + appendChild(child){ + if(child&&child.type==='fragment'){ + child.children.forEach(n=>this.children.push(n)); + }else{ + this.children.push(child); + } + return child; + } +} +global.document={ + createDocumentFragment(){ return new FakeNode('fragment'); }, + createTextNode(text){ return new FakeNode('text',String(text)); }, + createElement(tag){ const node=new FakeNode(tag); node.tagName=String(tag).toUpperCase(); return node; }, +}; +const body=new FakeNode('div'); +_streamFadeAppendText(body,'alpha beta '); +const firstSpan=body.children.find(node=>node.className==='stream-fade-word is-new'); +if(!firstSpan) throw new Error('missing first fade span'); +_streamFadeAppendText(body,'gamma'); +if(body.children.find(node=>node.className==='stream-fade-word is-new')!==firstSpan){ + throw new Error('first span was replaced'); +} +const spans=body.children.filter(node=>node.className==='stream-fade-word is-new'); +if(spans.length!==3) throw new Error(`expected three animated spans, got ${spans.length}`); +if(spans.map(node=>node.textContent).join('|')!=='alpha|beta|gamma'){ + throw new Error(`wrong span text: ${spans.map(node=>node.textContent).join('|')}`); +} +""" + ) + run_node(script) + + def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): anchor_block = function_block(MESSAGES_JS, "_anchorProseIncrementalNode") assert_contains_all( From 28d058ad2ea3642c9c467cd044a01bef723f6cb9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:32:53 -0400 Subject: [PATCH 04/11] Start fade spans when appended --- static/messages.js | 14 ++------------ tests/test_smooth_text_fade.py | 7 +++---- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/static/messages.js b/static/messages.js index f0ec6b0d..c5f8c211 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2543,7 +2543,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _streamFadeLastArrivalMs=0; let _streamFadeArrivalWps=0; let _streamFadeLatestAnimationEndAt=0; - let _streamFadeAppendOffset=0; let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=200; @@ -2560,7 +2559,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _lastRunJournalEventId=''; const _STREAM_FADE_MS=200; const _STREAM_FADE_MAX_MS=350; - const _STREAM_FADE_STAGGER_MS=16; const _STREAM_FADE_DONE_MAX_MS=320; const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; const _anchorApi=(typeof window!=='undefined'&&window.HermesAssistantTurnAnchors) @@ -4088,7 +4086,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeLastArrivalMs=0; _streamFadeArrivalWps=0; _streamFadeLatestAnimationEndAt=0; - _streamFadeAppendOffset=0; _streamFadeVisibleWords=0; _streamFadeHoldUntilMs=0; _streamFadeCurrentMs=_STREAM_FADE_MS; @@ -4162,13 +4159,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const span=document.createElement('span'); span.className='stream-fade-word is-new'; const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS; - const delayMs=_streamFadeAppendOffset*_STREAM_FADE_STAGGER_MS; - span.style.animationDelay=delayMs+'ms'; if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms'); span.textContent=match[1]; frag.appendChild(span); - _streamFadeAppendOffset+=1; - _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+delayMs+fadeMs); + _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+fadeMs); if(match[2]) frag.appendChild(document.createTextNode(match[2])); last=match.index+match[0].length; changed=true; @@ -4241,7 +4235,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const wordRe=/(\S+)(\s*)/g; const appendStartedAt=performance.now(); let last=0, match, changed=false; - _streamFadeAppendOffset=0; while((match=wordRe.exec(value))){ if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index))); if(reduceMotion){ @@ -4250,13 +4243,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const span=document.createElement('span'); span.className='stream-fade-word is-new'; const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS; - const delayMs=_streamFadeAppendOffset*_STREAM_FADE_STAGGER_MS; - span.style.animationDelay=delayMs+'ms'; if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms'); span.textContent=match[1]; frag.appendChild(span); - _streamFadeAppendOffset+=1; - _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+delayMs+fadeMs); + _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+fadeMs); } if(match[2]) frag.appendChild(document.createTextNode(match[2])); last=match.index+match[0].length; diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index f203fe58..c4f09425 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -82,14 +82,12 @@ let _streamFadeLastTargetWords=0; let _streamFadeLastArrivalMs=0; let _streamFadeArrivalWps=0; let _streamFadeLatestAnimationEndAt=0; -let _streamFadeAppendOffset=0; let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=200; let _streamFadeDomText=''; const _STREAM_FADE_MS=200; const _STREAM_FADE_MAX_MS=350; -const _STREAM_FADE_STAGGER_MS=16; const _STREAM_FADE_DONE_MAX_MS=320; const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; const performance={performance_stub}; @@ -202,6 +200,9 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): ["animationend", "span.replaceWith(document.createTextNode"], ) assert "_wrapStreamingFadeWords" not in MESSAGES_JS + assert "animationDelay" not in renderer_block + assert "_STREAM_FADE_STAGGER_MS" not in MESSAGES_JS + assert "_streamFadeAppendOffset" not in MESSAGES_JS def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): @@ -209,8 +210,6 @@ def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): function_block(MESSAGES_JS, "_streamFadeAppendText") + r""" const _STREAM_FADE_MS=200; -const _STREAM_FADE_STAGGER_MS=16; -let _streamFadeAppendOffset=0; let _streamFadeLatestAnimationEndAt=0; let _streamFadeCurrentMs=200; const performance={_t:0,now(){return this._t;}}; From 26eda1adbdc24e856b312291be637a782a327824 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:39:33 -0400 Subject: [PATCH 05/11] Make appended token fade perceptible --- static/messages.js | 10 +++++----- static/style.css | 4 ++-- tests/test_smooth_text_fade.py | 26 ++++++++++++++------------ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/static/messages.js b/static/messages.js index c5f8c211..398a654e 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2545,7 +2545,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _streamFadeLatestAnimationEndAt=0; let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; - let _streamFadeCurrentMs=200; + let _streamFadeCurrentMs=620; let _streamFadeDomText=''; let _streamFadeReduceMotionMql=null; let _streamFadeReduceMotion=false; @@ -2557,10 +2557,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0) : 0; let _lastRunJournalEventId=''; - const _STREAM_FADE_MS=200; - const _STREAM_FADE_MAX_MS=350; - const _STREAM_FADE_DONE_MAX_MS=320; - const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; + const _STREAM_FADE_MS=620; + const _STREAM_FADE_MAX_MS=900; + const _STREAM_FADE_DONE_MAX_MS=1000; + const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400; const _anchorApi=(typeof window!=='undefined'&&window.HermesAssistantTurnAnchors) ? window.HermesAssistantTurnAnchors : null; diff --git a/static/style.css b/static/style.css index 70509695..f65d17cc 100644 --- a/static/style.css +++ b/static/style.css @@ -6784,8 +6784,8 @@ main.main.showing-logs > #mainLogs{display:flex;} /* OpenWebUI-style streaming word fade (opt-in via Settings → Preferences). Opacity-only fade with JS-paced word/paragraph reveal. */ .stream-fade-active .stream-fade-word{display:inline;} -.stream-fade-word.is-new{animation:stream-fade-word-in var(--stream-fade-ms,240ms) cubic-bezier(.2,.7,.2,1) both;will-change:opacity;} -@keyframes stream-fade-word-in{0%{opacity:0;}45%{opacity:.45;}100%{opacity:1;}} +.stream-fade-word.is-new{animation:stream-fade-word-in var(--stream-fade-ms,620ms) cubic-bezier(.16,.84,.32,1) both;will-change:opacity;} +@keyframes stream-fade-word-in{0%{opacity:0;}35%{opacity:.18;}70%{opacity:.72;}100%{opacity:1;}} @media (prefers-reduced-motion: reduce){.stream-fade-word.is-new{animation:none;will-change:auto;}} [data-live-assistant="1"]:last-child .msg-body.stream-fade-active > :last-child::after, [data-live-assistant="1"]:last-child .msg-body.stream-fade-active:not(:has(> *))::after{display:none;content:none;} diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index c4f09425..b360a95a 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -84,12 +84,12 @@ let _streamFadeArrivalWps=0; let _streamFadeLatestAnimationEndAt=0; let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; -let _streamFadeCurrentMs=200; +let _streamFadeCurrentMs=620; let _streamFadeDomText=''; -const _STREAM_FADE_MS=200; -const _STREAM_FADE_MAX_MS=350; -const _STREAM_FADE_DONE_MAX_MS=320; -const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; +const _STREAM_FADE_MS=620; +const _STREAM_FADE_MAX_MS=900; +const _STREAM_FADE_DONE_MAX_MS=1000; +const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400; const performance={performance_stub}; {helpers} """ @@ -209,9 +209,9 @@ def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): script = ( function_block(MESSAGES_JS, "_streamFadeAppendText") + r""" -const _STREAM_FADE_MS=200; +const _STREAM_FADE_MS=620; let _streamFadeLatestAnimationEndAt=0; -let _streamFadeCurrentMs=200; +let _streamFadeCurrentMs=620; const performance={_t:0,now(){return this._t;}}; function _streamFadeReduceMotionEnabled(){ return false; } class FakeNode{ @@ -271,7 +271,7 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone") - assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=900" in MESSAGES_JS + assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400" in MESSAGES_JS assert_contains_all( drain_block, [ @@ -325,7 +325,9 @@ def test_stream_fade_css_is_opacity_only_and_hides_live_cursor(): [ "@keyframes stream-fade-word-in", ".stream-fade-word.is-new", - "var(--stream-fade-ms,240ms) cubic-bezier(.2,.7,.2,1)", + "var(--stream-fade-ms,620ms) cubic-bezier(.16,.84,.32,1)", + "35%{opacity:.18;}", + "70%{opacity:.72;}", "prefers-reduced-motion: reduce", ".msg-body.stream-fade-active > :last-child::after", "display:none", @@ -351,12 +353,12 @@ const words=Array.from({length:260},(_,i)=>'w'+i).join(' '); performance._t += 33; let out=_streamFadeNextText('slow start'); if(!out.changed) throw new Error('expected initial reveal'); -if(_streamFadeCurrentMs !== 200) throw new Error(`expected base fade 200ms, got ${_streamFadeCurrentMs}`); -for(let frame=0;frame<20&&_streamFadeCurrentMs<350;frame++){ +if(_streamFadeCurrentMs !== 620) throw new Error(`expected base fade 620ms, got ${_streamFadeCurrentMs}`); +for(let frame=0;frame<20&&_streamFadeCurrentMs<900;frame++){ performance._t += 120; out=_streamFadeNextText(words); } -if(_streamFadeCurrentMs !== 350) throw new Error(`expected max fade 350ms, got ${_streamFadeCurrentMs}`); +if(_streamFadeCurrentMs !== 900) throw new Error(`expected max fade 900ms, got ${_streamFadeCurrentMs}`); """ ) run_node(script) From 854949b07c68e5551ab2206b2e9e2ddfbd0bffd1 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:54:30 -0400 Subject: [PATCH 06/11] Keep streamed fade rows stationary --- static/ui.js | 85 +++++++++++++++---- ...ssue5367_transparent_live_row_reconcile.py | 67 ++++++++++++--- 2 files changed, 125 insertions(+), 27 deletions(-) diff --git a/static/ui.js b/static/ui.js index b6608aab..605b5e0a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11042,8 +11042,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ el.hidden=true; }); const liveFooter=blocks.querySelector('#liveRunStatus'); - let wrote=false; - const targetRenderedRows=[]; + const renderedRows=[]; for(const row of rows){ const node=_anchorSceneTransparentNodeForRow(row,{ live:true, @@ -11059,8 +11058,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ : node; if(existing) preserveByKey.delete(key); if(!renderedNode) continue; - targetRenderedRows.push(renderedNode); - wrote=true; + renderedRows.push(renderedNode); } const transparentLiveRowAlreadyPositioned=(node, expectedNextSibling)=>!!( node && @@ -11068,8 +11066,8 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ node.nextSibling===expectedNextSibling ); let expectedNextSibling=(liveFooter&&liveFooter.parentElement===blocks) ? liveFooter : null; - for(let i=targetRenderedRows.length-1;i>=0;i--){ - const renderedNode=targetRenderedRows[i]; + for(let i=renderedRows.length-1;i>=0;i--){ + const renderedNode=renderedRows[i]; if(transparentLiveRowAlreadyPositioned(renderedNode,expectedNextSibling)){ expectedNextSibling=renderedNode; continue; @@ -11079,7 +11077,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ expectedNextSibling=renderedNode; } preserveByKey.forEach(stale=>stale.remove()); - if(wrote) _syncTransparentEventControls(turn); + if(renderedRows.length) _syncTransparentEventControls(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); if(scrollRebuildGuard&&scrollRebuildGuard.release){ @@ -11089,7 +11087,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ }); } if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); - return wrote; + return !!renderedRows.length; } function _transparentLiveRowKey(node, streamId){ @@ -11184,6 +11182,69 @@ function _refreshTransparentThinkingLiveRow(existing, node){ return true; } +function _appendTransparentFadeText(body, text){ + if(!body) return; + const value = String(text || ''); + if(!value) return; + const reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + const frag = document.createDocumentFragment(); + const wordRe = /(\S+)(\s*)/g; + let last = 0, match, changed = false; + while((match = wordRe.exec(value))){ + if(match.index > last) frag.appendChild(document.createTextNode(value.slice(last, match.index))); + if(reduceMotion){ + frag.appendChild(document.createTextNode(match[1])); + }else{ + const span = document.createElement('span'); + span.className = 'stream-fade-word is-new'; + span.textContent = match[1]; + frag.appendChild(span); + } + if(match[2]) frag.appendChild(document.createTextNode(match[2])); + last = match.index + match[0].length; + changed = true; + } + if(!changed) frag.appendChild(document.createTextNode(value)); + else if(last < value.length) frag.appendChild(document.createTextNode(value.slice(last))); + 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) || ''); + const pairs = _transparentLiveRowAttributePairs(node); + const kept = Object.create(null); + for(const pair of pairs){ + const [name, value] = pair; + kept[String(name)] = String(value ?? ''); + } + for(const [name] of _transparentLiveRowAttributePairs(existing)){ + if(!Object.prototype.hasOwnProperty.call(kept, name)) existing.removeAttribute(name); + } + for(const pair of pairs){ + const [name, value] = pair; + existing.setAttribute(name, value); + } + existing.className = node.className || ''; + if(!body){ + body = document.createElement('div'); + body.className = 'msg-body'; + existing.appendChild(body); + } + if(body.classList) body.classList.add('stream-fade-active'); + if(!nextText.startsWith(currentText)){ + body.textContent = ''; + existing.setAttribute('data-stream-fade-text', ''); + _appendTransparentFadeText(body, nextText); + }else{ + _appendTransparentFadeText(body, nextText.slice(currentText.length)); + } + existing.setAttribute('data-stream-fade-text', nextText); + _rehydrateTransparentLiveRow(existing, node, preservedState); + return existing; +} + function _refreshTransparentLiveRow(existing, node){ if(!existing || !node || !existing.getAttribute) return node; if(existing===node) return existing; @@ -11192,13 +11253,7 @@ function _refreshTransparentLiveRow(existing, node){ node.querySelector && !!node.querySelector('.msg-body.stream-fade-active,.stream-fade-word'); if(candidateIsFadeProse){ - const parent = existing.parentNode; - if(parent){ - parent.insertBefore(node, existing); - existing.remove(); - } - _rehydrateTransparentLiveRow(node, node, preservedState); - return node; + return _refreshTransparentFadeProseRow(existing, node, preservedState); } const pairs = _transparentLiveRowAttributePairs(node); const kept = Object.create(null); diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index d18306d9..1c25b852 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -55,6 +55,7 @@ function extractFunc(name){{ return src.slice(start, i); }} class FakeElement {{ + static moves = 0; constructor(tag='div'){{ this.tagName = String(tag).toUpperCase(); this.children = []; @@ -143,7 +144,15 @@ class FakeElement {{ }} appendChild(child){{ this.appendChildCount = (this.appendChildCount || 0) + 1; - if(child && child.parentNode) child.remove(); + if(child && child.tagName === '#FRAGMENT'){{ + child.children.slice().forEach(grandchild=>this.appendChild(grandchild)); + child.children = []; + return child; + }} + if(child && child.parentNode){{ + FakeElement.moves += 1; + child.remove(); + }} if(!child) return null; child.parentNode = this; this.children.push(child); @@ -151,7 +160,10 @@ class FakeElement {{ }} insertBefore(child, refNode){{ this.insertBeforeCount = (this.insertBeforeCount || 0) + 1; - if(child && child.parentNode) child.remove(); + if(child && child.parentNode){{ + FakeElement.moves += 1; + child.remove(); + }} if(!child) return null; const idx = this.children.indexOf(refNode); child.parentNode = this; @@ -218,7 +230,15 @@ function matchesSimple(el, selector){{ }} global.window = {{}}; -global.document = {{ createElement:(tag)=>new FakeElement(tag) }}; +global.document = {{ + createElement:(tag)=>new FakeElement(tag), + createTextNode:(text)=>{{ + const node = new FakeElement('#text'); + node.textContent = text; + return node; + }}, + createDocumentFragment:()=>new FakeElement('#fragment'), +}}; global.CSS = {{ escape:(value)=>String(value) }}; global.requestAnimationFrame = (fn)=>fn(); @@ -286,6 +306,8 @@ eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); +eval(extractFunc('_appendTransparentFadeText')); +eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); @@ -330,6 +352,9 @@ const keptAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-r const staleAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-stale\"]'); const newAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-new\"]'); const rows = turn.children.filter((child) => child.classList.contains('transparent-event-row')); +const movesBeforeStableRender = FakeElement.moves; +const stableOrderRender = _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }}); +const movesAfterStableRender = FakeElement.moves; const idxs = {{ keptDirect: turn.children.indexOf(keptAfterSecond), freshDirect: turn.children.indexOf(newAfterSecond), @@ -385,9 +410,14 @@ fadeExisting.setAttribute('data-anchor-row-role', 'prose'); fadeExisting.setAttribute('data-anchor-row-id', 'fade-row'); fadeExisting.setAttribute('data-anchor-source-event-type', 'process_prose'); const staleBody = new FakeElement('div'); -staleBody.className = 'msg-body'; -staleBody.textContent = 'old flat text'; +staleBody.className = 'msg-body stream-fade-active'; +const oldSpan = new FakeElement('span'); +oldSpan.className = 'stream-fade-word is-new'; +oldSpan.textContent = 'old'; +staleBody.appendChild(oldSpan); +staleBody.appendChild((()=>{{ const n = new FakeElement('#text'); n.textContent = ' '; return n; }})()); fadeExisting.appendChild(staleBody); +fadeExisting.setAttribute('data-stream-fade-text', 'old '); fadeParent.appendChild(fadeExisting); const fadeCandidate = new FakeElement('div'); @@ -395,19 +425,24 @@ fadeCandidate.className = 'assistant-segment transparent-event-row'; fadeCandidate.setAttribute('data-anchor-row-role', 'prose'); fadeCandidate.setAttribute('data-anchor-row-id', 'fade-row'); fadeCandidate.setAttribute('data-anchor-source-event-type', 'process_prose'); +fadeCandidate.dataset.rawText = 'old new'; const fadeBody = new FakeElement('div'); fadeBody.className = 'msg-body stream-fade-active'; const fadeSpan = new FakeElement('span'); fadeSpan.className = 'stream-fade-word is-new'; -fadeSpan.textContent = 'new'; +fadeSpan.textContent = 'old'; fadeBody.appendChild(fadeSpan); +fadeBody.appendChild((()=>{{ const n = new FakeElement('#text'); n.textContent = ' new'; return n; }})()); fadeCandidate.appendChild(fadeBody); const fadeRefresh = _refreshTransparentLiveRow(fadeExisting, fadeCandidate); +const fadeSpans = fadeParent.querySelectorAll('.stream-fade-word.is-new'); process.stdout.write(JSON.stringify({{ firstRender, secondRender, + stableOrderRender, sameNode: keptAfterFirst === keptAfterSecond, + stableOrderMovedRows: movesAfterStableRender - movesBeforeStableRender, keptId: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-row-id'), keptSource: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-source-event-type'), keptText: keptAfterSecond && keptAfterSecond.textContent, @@ -428,9 +463,12 @@ process.stdout.write(JSON.stringify({{ keylessAfterFourth: keylessRowsAfterFourth.length, keylessTextsAfterFourth: keylessRowsAfterFourth.map((child) => child.textContent), totalRowsAfterFourth: turn.children.filter((child) => child.classList.contains('transparent-event-row')).length, - fadeReplacedWithCandidate: fadeRefresh === fadeCandidate, - fadeExistingRemoved: fadeExisting.parentNode === null, - fadeCandidateParented: fadeCandidate.parentNode === fadeParent, + fadeKeptExisting: fadeRefresh === fadeExisting, + fadeExistingStillParented: fadeExisting.parentNode === fadeParent, + fadeCandidateDetached: fadeCandidate.parentNode === null, + fadeOldSpanPreserved: fadeSpans[0] === oldSpan, + fadeSpanTexts: fadeSpans.map(span=>span.textContent), + fadeText: fadeExisting.getAttribute('data-stream-fade-text'), fadeSpanPreserved: !!fadeParent.querySelector('.stream-fade-word.is-new'), fadeBodyActive: !!fadeParent.querySelector('.msg-body.stream-fade-active'), }})); @@ -439,7 +477,9 @@ process.stdout.write(JSON.stringify({{ data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["firstRender"] is True assert data["secondRender"] is True + assert data["stableOrderRender"] is True assert data["sameNode"] is True + assert data["stableOrderMovedRows"] == 0 assert data["keptId"] == "row-kept" assert data["keptSource"] == "process_prose" assert data["keptText"] == "updated progress line" @@ -469,9 +509,12 @@ process.stdout.write(JSON.stringify({{ assert data["keylessAfterFourth"] == 1 assert data["keylessTextsAfterFourth"] == ["keyless row second"] assert data["totalRowsAfterFourth"] == 1 - assert data["fadeReplacedWithCandidate"] is True - assert data["fadeExistingRemoved"] is True - assert data["fadeCandidateParented"] is True + assert data["fadeKeptExisting"] is True + assert data["fadeExistingStillParented"] is True + assert data["fadeCandidateDetached"] is True + assert data["fadeOldSpanPreserved"] is True + assert data["fadeSpanTexts"] == ["old", "new"] + assert data["fadeText"] == "old new" assert data["fadeSpanPreserved"] is True assert data["fadeBodyActive"] is True From 86e55c785605d7057cc88d78ea727e7cebd4b98d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 22:59:51 -0400 Subject: [PATCH 07/11] Feed transparent fade rows revealed text --- static/messages.js | 4 +++- ...test_live_to_final_anchor_visible_order.py | 3 ++- tests/test_smooth_text_fade.py | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 398a654e..61151290 100644 --- a/static/messages.js +++ b/static/messages.js @@ -4772,8 +4772,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const displayText = segmentStart===0 ? parsed.displayText // first segment: uses think-tag stripping : _stripXmlToolCalls(assistantText.slice(segmentStart)); + let anchorProcessText=displayText; if(_shouldUseStreamFade()){ const caughtUp=_renderStreamingFadeMarkdown(displayText); + anchorProcessText=_streamFadeDomText||''; if(!caughtUp&&!_streamFinalized){ setTimeout(()=>_scheduleRender(), 33); } @@ -4798,7 +4800,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText); } } - _upsertAnchorProcessProse(displayText); + if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText); if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText); } scrollIfPinned(); diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 3e4bceb7..948b0732 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -187,7 +187,8 @@ def test_process_prose_is_an_anchor_scene_row_not_a_dom_mirror(): assert "_upsertAnchorProcessProse(displayText,{sealed:force})" in flush assert "function _upsertAnchorProcessProse" in MESSAGES_JS assert "source_event_type:sourceEventType" in _function_body(MESSAGES_JS, "_applyToAnchor") - assert "_upsertAnchorProcessProse(displayText)" in schedule + assert "let anchorProcessText=displayText" in schedule + assert "_upsertAnchorProcessProse(anchorProcessText)" in schedule assert "function _replaceAnchorActivityEventByLocalId" in MESSAGES_JS assert "events[i]=next" in MESSAGES_JS assert "_renderAnchorLiveScene();" in _function_body(MESSAGES_JS, "_upsertAnchorProcessProse") diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index b360a95a..9788cdcb 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -269,6 +269,26 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): ) +def test_transparent_anchor_prose_receives_revealed_fade_text(): + render_section = slice_between( + MESSAGES_JS, + "const displayText = segmentStart===0", + "if(typeof _syncLiveWorklogReasonsForAnchor==='function')", + ) + assert_contains_all( + render_section, + [ + "let anchorProcessText=displayText", + "const caughtUp=_renderStreamingFadeMarkdown(displayText)", + "anchorProcessText=_streamFadeDomText||''", + "if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)", + ], + ) + assert render_section.index("anchorProcessText=_streamFadeDomText||''") < render_section.index( + "_upsertAnchorProcessProse(anchorProcessText)" + ) + + def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone") assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400" in MESSAGES_JS From 662f492b2fcef9d3f72fb82379dd6d8a982653de Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 23:09:14 -0400 Subject: [PATCH 08/11] Clean up transparent fade spans after animation --- static/ui.js | 11 +++++++++++ .../test_issue5367_transparent_live_row_reconcile.py | 1 + 2 files changed, 12 insertions(+) diff --git a/static/ui.js b/static/ui.js index 605b5e0a..4c1d5a57 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11182,10 +11182,21 @@ function _refreshTransparentThinkingLiveRow(existing, node){ return true; } +function _bindTransparentFadeCleanup(body){ + if(!body || body._transparentFadeCleanupBound || typeof body.addEventListener !== 'function') return; + body._transparentFadeCleanupBound = true; + body.addEventListener('animationend', e=>{ + const span = e.target; + if(!span || !span.classList || !span.classList.contains('stream-fade-word')) return; + span.replaceWith(document.createTextNode(span.textContent || '')); + }); +} + function _appendTransparentFadeText(body, text){ if(!body) return; const value = String(text || ''); if(!value) return; + _bindTransparentFadeCleanup(body); const reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); const frag = document.createDocumentFragment(); const wordRe = /(\S+)(\s*)/g; diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 1c25b852..509f727d 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -306,6 +306,7 @@ eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); +eval(extractFunc('_bindTransparentFadeCleanup')); eval(extractFunc('_appendTransparentFadeText')); eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentLiveRow')); From 3918dd132e30ae924cac7e4eeb8ad1d251bf54c0 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 23:20:40 -0400 Subject: [PATCH 09/11] Fade transparent prose without preference gate --- static/messages.js | 11 +++++++---- tests/test_smooth_text_fade.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/static/messages.js b/static/messages.js index 61151290..c1e55678 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3636,7 +3636,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _anchorProseIncrementalNode(key, text){ if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null; const value=String(text||''); - const fade=typeof _shouldUseStreamFade==='function'&&_shouldUseStreamFade(); + const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade(); try{ let st=_anchorProseSmdCache.get(key); // Self-heal desyncs (edit/sanitize made the text no longer a pure append): @@ -4101,6 +4101,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _shouldUseStreamFade(){ return window._fadeTextEffect===true; } + function _shouldUseLiveProseFade(){ + return _shouldUseStreamFade() || (typeof isTransparentStream==='function'&&isTransparentStream()); + } function _streamFadeSkipNode(node){ if(!node||node.nodeType!==1) return false; const tag=(node.tagName||'').toLowerCase(); @@ -4773,7 +4776,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ? parsed.displayText // first segment: uses think-tag stripping : _stripXmlToolCalls(assistantText.slice(segmentStart)); let anchorProcessText=displayText; - if(_shouldUseStreamFade()){ + if(_shouldUseLiveProseFade()){ const caughtUp=_renderStreamingFadeMarkdown(displayText); anchorProcessText=_streamFadeDomText||''; if(!caughtUp&&!_streamFinalized){ @@ -4806,7 +4809,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ scrollIfPinned(); _throttledSnapshotLiveTurn(); }; - const frameIntervalMs=_shouldUseStreamFade()?33:66; + const frameIntervalMs=_shouldUseLiveProseFade()?33:66; if(sinceLastMs>=frameIntervalMs){ _pendingRafHandle=requestAnimationFrame(_doRender); } else { @@ -5499,7 +5502,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }); sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid}); }; - if(_shouldUseStreamFade()&&assistantBody){ + if(_shouldUseLiveProseFade()&&assistantBody){ _cancelAnimationFramePendingStreamRender(); _drainStreamFadeBeforeDone(_finishDone); return; diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 9788cdcb..b0b82846 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -256,10 +256,11 @@ if(spans.map(node=>node.textContent).join('|')!=='alpha|beta|gamma'){ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): anchor_block = function_block(MESSAGES_JS, "_anchorProseIncrementalNode") + predicate_block = function_block(MESSAGES_JS, "_shouldUseLiveProseFade") assert_contains_all( anchor_block, [ - "const fade=typeof _shouldUseStreamFade==='function'&&_shouldUseStreamFade()", + "const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade()", "if(st && st.fade!==fade) st=null", "if(body.classList) body.classList.toggle('stream-fade-active',fade)", "const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body)", @@ -267,6 +268,13 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): "const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body')", ], ) + assert_contains_all( + predicate_block, + [ + "_shouldUseStreamFade()", + "typeof isTransparentStream==='function'&&isTransparentStream()", + ], + ) def test_transparent_anchor_prose_receives_revealed_fade_text(): @@ -280,6 +288,7 @@ def test_transparent_anchor_prose_receives_revealed_fade_text(): [ "let anchorProcessText=displayText", "const caughtUp=_renderStreamingFadeMarkdown(displayText)", + "if(_shouldUseLiveProseFade())", "anchorProcessText=_streamFadeDomText||''", "if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)", ], From 45a203b36bb324c4bed941f02161dd416ad66e63 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 23:40:48 -0400 Subject: [PATCH 10/11] fix(#5493): preserve anchor prose before body mount --- static/messages.js | 31 +++++++++++++++++++++++++------ tests/test_smooth_text_fade.py | 27 +++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/static/messages.js b/static/messages.js index c1e55678..06902521 100644 --- a/static/messages.js +++ b/static/messages.js @@ -4101,8 +4101,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _shouldUseStreamFade(){ return window._fadeTextEffect===true; } + function _shouldUseTransparentStreamFade(){ + return typeof isTransparentStream==='function'&&isTransparentStream(); + } function _shouldUseLiveProseFade(){ - return _shouldUseStreamFade() || (typeof isTransparentStream==='function'&&isTransparentStream()); + return _shouldUseStreamFade() || _shouldUseTransparentStreamFade(); } function _streamFadeSkipNode(node){ if(!node||node.nodeType!==1) return false; @@ -4365,6 +4368,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const next=_streamFadeNextText(displayText); if(!next.changed) return next.caughtUp; assistantBody.classList.add('stream-fade-active'); + if(!_shouldUseTransparentStreamFade()){ + if(!_smdParser&&window.smd){ + if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;} + _smdNewParser(assistantBody,true); + } + if(_smdParser){ + _smdWrite(next.text,true); + }else{ + assistantBody.innerHTML=renderMd ? renderMd(next.text||'') : esc(next.text||''); + _sanitizeSmdLinks(assistantBody); + } + _streamFadeDomText=String(next.text||''); + return next.caughtUp; + } _streamFadeBindCleanup(assistantBody); if(_smdParser){ _smdEndParser(); @@ -4397,6 +4414,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!assistantBody){onDone();return;} const target=_streamFadeCurrentDisplayText(); const caughtUp=_renderStreamingFadeMarkdown(target); + const anchorProcessText=_streamFadeDomText||target; + if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText); scrollIfPinned(); if(caughtUp){ // parser_end can flush pending markdown text; include that final text in @@ -4771,11 +4790,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const parsed=_cachedParsed&&_cachedParsedText===assistantText&&_cachedParsedReasoning===liveReasoningText ? _cachedParsed : _parseStreamState(); _cachedParsed=null; _renderLiveThinking(parsed); + const displayText = segmentStart===0 + ? parsed.displayText // first segment: uses think-tag stripping + : _stripXmlToolCalls(assistantText.slice(segmentStart)); + let anchorProcessText=displayText; if(assistantBody){ - const displayText = segmentStart===0 - ? parsed.displayText // first segment: uses think-tag stripping - : _stripXmlToolCalls(assistantText.slice(segmentStart)); - let anchorProcessText=displayText; if(_shouldUseLiveProseFade()){ const caughtUp=_renderStreamingFadeMarkdown(displayText); anchorProcessText=_streamFadeDomText||''; @@ -4803,9 +4822,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText); } } - if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText); if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText); } + if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText); scrollIfPinned(); _throttledSnapshotLiveTurn(); }; diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index b0b82846..aebb5dce 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -165,14 +165,19 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): [ "_streamFadeNextText(displayText)", "if(!next.changed) return next.caughtUp", + "if(!_shouldUseTransparentStreamFade())", + "_smdNewParser(assistantBody,true)", + "_smdWrite(next.text,true)", + "_sanitizeSmdLinks(assistantBody)", "_streamFadeBindCleanup(assistantBody)", "_streamFadeAppendText(assistantBody,delta)", "_streamFadeDomText=String(next.text||'')", "stream-fade-active", ], ) - assert "_smdWrite(next.text,true)" not in render_block - assert "innerHTML" not in render_block + assert render_block.index("_smdWrite(next.text,true)") < render_block.index( + "_streamFadeAppendText(assistantBody,delta)" + ) append_block = function_block(MESSAGES_JS, "_streamFadeAppendText") assert_contains_all( append_block, @@ -272,30 +277,37 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): predicate_block, [ "_shouldUseStreamFade()", - "typeof isTransparentStream==='function'&&isTransparentStream()", + "_shouldUseTransparentStreamFade()", ], ) + assert "function _shouldUseTransparentStreamFade()" in MESSAGES_JS + assert "typeof isTransparentStream==='function'&&isTransparentStream()" in MESSAGES_JS def test_transparent_anchor_prose_receives_revealed_fade_text(): render_section = slice_between( MESSAGES_JS, "const displayText = segmentStart===0", - "if(typeof _syncLiveWorklogReasonsForAnchor==='function')", + "scrollIfPinned();", ) assert_contains_all( render_section, [ "let anchorProcessText=displayText", + "if(assistantBody){", "const caughtUp=_renderStreamingFadeMarkdown(displayText)", "if(_shouldUseLiveProseFade())", "anchorProcessText=_streamFadeDomText||''", "if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)", ], ) + assert render_section.index("let anchorProcessText=displayText") < render_section.index("if(assistantBody){") assert render_section.index("anchorProcessText=_streamFadeDomText||''") < render_section.index( "_upsertAnchorProcessProse(anchorProcessText)" ) + assert render_section.index("if(assistantBody){") < render_section.rindex( + "if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)" + ) def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): @@ -305,11 +317,18 @@ def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): drain_block, [ "const drainStartedAt=performance.now();", + "const target=_streamFadeCurrentDisplayText();", + "const caughtUp=_renderStreamingFadeMarkdown(target);", + "const anchorProcessText=_streamFadeDomText||target;", + "if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);", "performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS", "if(_smdParser) _smdEndParser();", "onDone();", ], ) + assert drain_block.index("_renderStreamingFadeMarkdown(target)") < drain_block.index( + "_upsertAnchorProcessProse(anchorProcessText)" + ) def test_live_streaming_assistant_content_opts_out_of_global_theme_transitions(): From 0c1bdce02377802e07cf34db80e0edf6f9790f23 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 4 Jul 2026 10:07:34 -0400 Subject: [PATCH 11/11] fix(#5493): honor reduced motion for transparent fade --- static/messages.js | 5 +- tests/test_smooth_text_fade.py | 88 ++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/static/messages.js b/static/messages.js index 06902521..4d699435 100644 --- a/static/messages.js +++ b/static/messages.js @@ -4105,7 +4105,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return typeof isTransparentStream==='function'&&isTransparentStream(); } function _shouldUseLiveProseFade(){ - return _shouldUseStreamFade() || _shouldUseTransparentStreamFade(); + return !_streamFadeReduceMotionEnabled() && (_shouldUseStreamFade() || _shouldUseTransparentStreamFade()); } function _streamFadeSkipNode(node){ if(!node||node.nodeType!==1) return false; @@ -4382,7 +4382,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeDomText=String(next.text||''); return next.caughtUp; } - _streamFadeBindCleanup(assistantBody); if(_smdParser){ _smdEndParser(); assistantBody.textContent=''; @@ -4397,7 +4396,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeDomText=''; } const delta=String(next.text||'').slice(_streamFadeDomText.length); - _streamFadeAppendText(assistantBody,delta); + if(delta) assistantBody.appendChild(document.createTextNode(delta)); _streamFadeDomText=String(next.text||''); return next.caughtUp; } diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index aebb5dce..6950936b 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -169,15 +169,16 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): "_smdNewParser(assistantBody,true)", "_smdWrite(next.text,true)", "_sanitizeSmdLinks(assistantBody)", - "_streamFadeBindCleanup(assistantBody)", - "_streamFadeAppendText(assistantBody,delta)", + "assistantBody.appendChild(document.createTextNode(delta))", "_streamFadeDomText=String(next.text||'')", "stream-fade-active", ], ) assert render_block.index("_smdWrite(next.text,true)") < render_block.index( - "_streamFadeAppendText(assistantBody,delta)" + "assistantBody.appendChild(document.createTextNode(delta))" ) + assert "_streamFadeAppendText(assistantBody,delta)" not in render_block + assert "_streamFadeBindCleanup(assistantBody)" not in render_block append_block = function_block(MESSAGES_JS, "_streamFadeAppendText") assert_contains_all( append_block, @@ -276,6 +277,7 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): assert_contains_all( predicate_block, [ + "!_streamFadeReduceMotionEnabled()", "_shouldUseStreamFade()", "_shouldUseTransparentStreamFade()", ], @@ -284,6 +286,86 @@ def test_transparent_anchor_prose_uses_fade_renderer_when_enabled(): assert "typeof isTransparentStream==='function'&&isTransparentStream()" in MESSAGES_JS +def test_reduced_motion_disables_live_prose_fade_predicate(): + script = ( + "\n".join( + function_block(MESSAGES_JS, name) + for name in [ + "_shouldUseStreamFade", + "_shouldUseTransparentStreamFade", + "_streamFadeReduceMotionEnabled", + "_shouldUseLiveProseFade", + ] + ) + + r""" +let _streamFadeReduceMotionMql=null; +let _streamFadeReduceMotion=false; +let _streamFadeReduceMotionOnChange=null; +let transparent=true; +let reduceMotion=true; +global.window={ + _fadeTextEffect:true, + matchMedia(){ + return { + get matches(){ return reduceMotion; }, + addEventListener(){}, + removeEventListener(){}, + }; + }, +}; +function isTransparentStream(){ return transparent; } +if(_shouldUseLiveProseFade()) throw new Error('reduced motion allowed live prose fade'); +_streamFadeReduceMotionMql=null; +reduceMotion=false; +window._fadeTextEffect=false; +if(!_shouldUseLiveProseFade()) throw new Error('transparent stream fade should work when motion is allowed'); +_streamFadeReduceMotionMql=null; +transparent=false; +window._fadeTextEffect=true; +if(!_shouldUseLiveProseFade()) throw new Error('regular fade preference should work when motion is allowed'); +""" + ) + run_node(script) + + +def test_transparent_stream_hidden_body_appends_plain_text_only(): + script = ( + function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown") + + r""" +let _streamFadeDomText=''; +let _smdParser=null; +let _smdReconnect=false; +let parserEnded=false; +function _streamFadeNextText(){ return {changed:true,caughtUp:false,text:'alpha beta'}; } +function _shouldUseTransparentStreamFade(){ return true; } +function _smdEndParser(){ parserEnded=true; } +const assistantBody={ + textContent:'', + innerHTML:'', + children:[], + classList:{added:[],add(name){ this.added.push(name); }}, + appendChild(node){ + this.children.push(node); + this.textContent += String(node.textContent || ''); + return node; + }, +}; +global.document={ + createTextNode(text){ return {type:'text',textContent:String(text)}; }, +}; +const caughtUp=_renderStreamingFadeMarkdown('alpha beta'); +if(caughtUp) throw new Error('expected fade playout to remain catching up'); +if(assistantBody.textContent!=='alpha beta') throw new Error(`wrong hidden text: ${assistantBody.textContent}`); +if(_streamFadeDomText!=='alpha beta') throw new Error(`wrong dom text: ${_streamFadeDomText}`); +if(assistantBody.children.some(node=>node.className==='stream-fade-word is-new')){ + throw new Error('hidden body received fade span'); +} +if(!assistantBody.classList.added.includes('stream-fade-active')) throw new Error('missing stream fade active marker'); +""" + ) + run_node(script) + + def test_transparent_anchor_prose_receives_revealed_fade_text(): render_section = slice_between( MESSAGES_JS,