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(