diff --git a/static/messages.js b/static/messages.js index 5419e8c4..efae04f6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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=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; diff --git a/static/style.css b/static/style.css index d6acf7c3..b16d135c 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/ui.js b/static/ui.js index 571ecb27..0c039d07 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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){ diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 944f547c..509f727d 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,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") diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index bc3356a2..a92feda5 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -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") diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 5a1cd78a..6950936b 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -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)