This commit is contained in:
nesquena-hermes committed 2026-07-06 00:16:29 +00:00
commit e6b2ac9119
6 files changed
+488 -56

No files matched your search

+92 -29
View File
@@ -2546,10 +2546,10 @@ 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;
let _streamFadeCurrentMs=620;
let _streamFadeDomText='';
let _streamFadeReduceMotionMql=null;
let _streamFadeReduceMotion=false;
let _streamFadeReduceMotionOnChange=null;
@@ -2560,11 +2560,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_STAGGER_MS=16;
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;
@@ -3669,21 +3668,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 _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade();
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.
@@ -3692,6 +3694,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);
@@ -4114,10 +4118,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_streamFadeLastArrivalMs=0;
_streamFadeArrivalWps=0;
_streamFadeLatestAnimationEndAt=0;
_streamFadeAppendOffset=0;
_streamFadeVisibleWords=0;
_streamFadeHoldUntilMs=0;
_streamFadeCurrentMs=_STREAM_FADE_MS;
_streamFadeDomText='';
}
function _cancelAnimationFramePendingStreamRender(){
if(_pendingRafHandle===null) return;
@@ -4129,6 +4133,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _shouldUseStreamFade(){
return window._fadeTextEffect===true;
}
function _shouldUseTransparentStreamFade(){
return typeof isTransparentStream==='function'&&isTransparentStream();
}
function _shouldUseLiveProseFade(){
return !_streamFadeReduceMotionEnabled() && (_shouldUseStreamFade() || _shouldUseTransparentStreamFade());
}
function _streamFadeSkipNode(node){
if(!node||node.nodeType!==1) return false;
const tag=(node.tagName||'').toLowerCase();
@@ -4187,13 +4197,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;
@@ -4257,6 +4264,39 @@ 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;
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;
if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms');
span.textContent=match[1];
frag.appendChild(span);
_streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+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();
@@ -4360,17 +4400,36 @@ 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);
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;
}
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);
if(delta) assistantBody.appendChild(document.createTextNode(delta));
_streamFadeDomText=String(next.text||'');
return next.caughtUp;
}
function _streamFadeCurrentDisplayText(){
@@ -4386,6 +4445,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
@@ -4760,12 +4821,14 @@ 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));
if(_shouldUseStreamFade()){
if(_shouldUseLiveProseFade()){
const caughtUp=_renderStreamingFadeMarkdown(displayText);
anchorProcessText=_streamFadeDomText||'';
if(!caughtUp&&!_streamFinalized){
setTimeout(()=>_scheduleRender(), 33);
}
@@ -4790,13 +4853,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
}
}
_upsertAnchorProcessProse(displayText);
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
}
if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);
scrollIfPinned();
_throttledSnapshotLiveTurn();
};
const frameIntervalMs=_shouldUseStreamFade()?33:66;
const frameIntervalMs=_shouldUseLiveProseFade()?33:66;
if(sinceLastMs>=frameIntervalMs){
_pendingRafHandle=requestAnimationFrame(_doRender);
} else {
@@ -5489,7 +5552,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;
+2 -2
View File
@@ -6905,8 +6905,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;}
+86 -8
View File
@@ -11372,8 +11372,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,
@@ -11389,8 +11388,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 &&
@@ -11398,8 +11396,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;
@@ -11409,7 +11407,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){
@@ -11419,7 +11417,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
});
}
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
return wrote;
return !!renderedRows.length;
}
function _transparentLiveRowKey(node, streamId){
@@ -11514,10 +11512,90 @@ 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;
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;
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){
return _refreshTransparentFadeProseRow(existing, node, preservedState);
}
const pairs = _transparentLiveRowAttributePairs(node);
const kept = Object.create(null);
for(const pair of pairs){
@@ -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,9 @@ eval(extractFunc('_transparentLiveRowAttributePairs'));
eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_bindTransparentFadeCleanup'));
eval(extractFunc('_appendTransparentFadeText'));
eval(extractFunc('_refreshTransparentFadeProseRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
@@ -330,6 +353,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),
@@ -378,10 +404,46 @@ 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 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');
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 = '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,
@@ -402,13 +464,23 @@ 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,
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'),
}}));
"""
script = script.replace("{{", "{").replace("}}", "}")
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"
@@ -438,6 +510,14 @@ process.stdout.write(JSON.stringify({{
assert data["keylessAfterFourth"] == 1
assert data["keylessTextsAfterFourth"] == ["keyless row second"]
assert data["totalRowsAfterFourth"] == 1
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
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
@@ -306,7 +306,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")
+223 -13
View File
@@ -82,15 +82,14 @@ let _streamFadeLastTargetWords=0;
let _streamFadeLastArrivalMs=0;
let _streamFadeArrivalWps=0;
let _streamFadeLatestAnimationEndAt=0;
let _streamFadeAppendOffset=0;
let _streamFadeVisibleWords=0;
let _streamFadeHoldUntilMs=0;
let _streamFadeCurrentMs=200;
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;
let _streamFadeCurrentMs=620;
let _streamFadeDomText='';
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}
"""
@@ -166,12 +165,30 @@ 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)",
"assistantBody.appendChild(document.createTextNode(delta))",
"_streamFadeDomText=String(next.text||'')",
"stream-fade-active",
],
)
assert "renderMd ? renderMd(next.text||'')" in render_block
assert render_block.index("_smdWrite(next.text,true)") < render_block.index(
"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,
[
"document.createDocumentFragment()",
"span.className='stream-fade-word is-new'",
"el.appendChild(frag)",
"_streamFadeLatestAnimationEndAt",
],
)
assert_contains_all(
renderer_block,
[
@@ -189,20 +206,211 @@ 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():
script = (
function_block(MESSAGES_JS, "_streamFadeAppendText")
+ r"""
const _STREAM_FADE_MS=620;
let _streamFadeLatestAnimationEndAt=0;
let _streamFadeCurrentMs=620;
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")
predicate_block = function_block(MESSAGES_JS, "_shouldUseLiveProseFade")
assert_contains_all(
anchor_block,
[
"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)",
"st={node,parser:window.smd.parser(renderer),writtenText:'',fade}",
"const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body')",
],
)
assert_contains_all(
predicate_block,
[
"!_streamFadeReduceMotionEnabled()",
"_shouldUseStreamFade()",
"_shouldUseTransparentStreamFade()",
],
)
assert "function _shouldUseTransparentStreamFade()" in MESSAGES_JS
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,
"const displayText = segmentStart===0",
"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():
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,
[
"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():
@@ -247,7 +455,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",
@@ -273,12 +483,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)