Append fade spans instead of replaying streamed prose

This commit is contained in:
Rod Boev committed 2026-07-04 10:02:39 -04:00
1 parent fbebd0bd30
commit af2f534f21
2 files changed
+121 -12

No files matched your search

+54 -9
View File
@@ -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<value.length){
frag.appendChild(document.createTextNode(value.slice(last)));
}
el.appendChild(frag);
}
function _streamFadePauseAfter(text, paragraphBreakIndex){
if(paragraphBreakIndex>=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(){
+67 -3
View File
@@ -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(