Release: restore prose-level fade for Transparent Stream (#5506) - #5652
Conversation
Re-adds the per-word prose fade for newly-streamed assistant text in Transparent Stream WITHOUT reintroducing the row-level entrance flicker (#5367). Reduced-motion honored at both JS and CSS layers; thinking/tool rows not animated. Option A: the fade is part of Transparent Stream regardless of the off-by-default 'Fade text effect' toggle; settings description clarified accordingly. Gate: Codex SAFE, Fable SHIP-UX, suite 12158/0, browser-smoke clean. Co-authored-by: Rod Boev <rod.boev@gmail.com>
|
| Filename | Overview |
|---|---|
| static/messages.js | Adds transparent-stream fade path to _renderStreamingFadeMarkdown, new _shouldUseLiveProseFade / _shouldUseTransparentStreamFade predicates, and _streamFadeAppendText (defined but never called from production code). Animation constants tripled (200→620ms base, 350→900ms max). Stagger/offset logic correctly removed. |
| static/ui.js | Adds _bindTransparentFadeCleanup, _appendTransparentFadeText, _refreshTransparentFadeProseRow, and the candidateIsFadeProse gate in _refreshTransparentLiveRow. The rawText→plain-text path in _refreshTransparentFadeProseRow could briefly expose raw markdown on rare cache-reset; normal streaming is unaffected. |
| static/style.css | Updates animation defaults to 620ms, new easing and keyframe curve. Reduced-motion media query unchanged and correct. |
| static/index.html | Settings description for 'Fade text effect' updated to clarify that Transparent Stream always applies the prose fade regardless of the toggle. Accurate and consistent with the design decision. |
| tests/test_smooth_text_fade.py | Adds six new tests covering the transparent-stream hidden-body path, reduced-motion predicate, anchor-prose fade renderer wiring, drain-loop _upsertAnchorProcessProse ordering, and the new animation constants. Good structural-assertion coverage. |
| tests/test_issue5367_transparent_live_row_reconcile.py | Extends the row-reconcile test harness with DocumentFragment support, DOM-move tracking, and a full _refreshTransparentFadeProseRow scenario verifying incremental span append without replacing existing nodes. |
| tests/test_live_to_final_anchor_visible_order.py | One assertion updated to reflect the renamed anchorProcessText variable used in the scheduling block — correctly tracks the code change. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant SSE as SSE Stream
participant doRender as _doRender (RAF)
participant rsfm as _renderStreamingFadeMarkdown
participant uapp as _upsertAnchorProcessProse
participant apin as _anchorProseIncrementalNode
participant scene as _renderLiveAnchorActivitySceneTransparent
participant refresh as _refreshTransparentFadeProseRow
SSE->>doRender: new assistant text
doRender->>rsfm: displayText (throttled by _streamFadeNextText)
alt transparent stream mode
rsfm->>rsfm: append plain createTextNode to hidden assistantBody
rsfm->>rsfm: update _streamFadeDomText
rsfm-->>doRender: "anchorProcessText = _streamFadeDomText"
else regular stream fade
rsfm->>rsfm: _smdWrite via _streamFadeRenderer (spans)
rsfm-->>doRender: "anchorProcessText = _streamFadeDomText"
end
doRender->>uapp: anchorProcessText
uapp->>apin: key, anchorProcessText
apin->>apin: write delta to SMD parser via _streamFadeRenderer
apin->>apin: "set node.dataset.rawText = anchorProcessText"
apin-->>uapp: cached node (with stream-fade-word spans)
uapp->>scene: render anchor scene rows
scene->>scene: find existing DOM node by key
alt "existing === candidate (cache hit, normal path)"
scene-->>scene: return existing (no refresh needed)
else "existing != candidate (cache reset / reconnect)"
scene->>refresh: _refreshTransparentFadeProseRow(existing, candidate)
refresh->>refresh: compute delta from rawText vs data-stream-fade-text
refresh->>refresh: _appendTransparentFadeText (new word spans only)
refresh-->>scene: updated existing node
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant SSE as SSE Stream
participant doRender as _doRender (RAF)
participant rsfm as _renderStreamingFadeMarkdown
participant uapp as _upsertAnchorProcessProse
participant apin as _anchorProseIncrementalNode
participant scene as _renderLiveAnchorActivitySceneTransparent
participant refresh as _refreshTransparentFadeProseRow
SSE->>doRender: new assistant text
doRender->>rsfm: displayText (throttled by _streamFadeNextText)
alt transparent stream mode
rsfm->>rsfm: append plain createTextNode to hidden assistantBody
rsfm->>rsfm: update _streamFadeDomText
rsfm-->>doRender: "anchorProcessText = _streamFadeDomText"
else regular stream fade
rsfm->>rsfm: _smdWrite via _streamFadeRenderer (spans)
rsfm-->>doRender: "anchorProcessText = _streamFadeDomText"
end
doRender->>uapp: anchorProcessText
uapp->>apin: key, anchorProcessText
apin->>apin: write delta to SMD parser via _streamFadeRenderer
apin->>apin: "set node.dataset.rawText = anchorProcessText"
apin-->>uapp: cached node (with stream-fade-word spans)
uapp->>scene: render anchor scene rows
scene->>scene: find existing DOM node by key
alt "existing === candidate (cache hit, normal path)"
scene-->>scene: return existing (no refresh needed)
else "existing != candidate (cache reset / reconnect)"
scene->>refresh: _refreshTransparentFadeProseRow(existing, candidate)
refresh->>refresh: compute delta from rawText vs data-stream-fade-text
refresh->>refresh: _appendTransparentFadeText (new word spans only)
refresh-->>scene: updated existing node
end
Reviews (1): Last reviewed commit: "release: restore prose-level fade for Tr..." | Re-trigger Greptile
| 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); | ||
| } |
There was a problem hiding this comment.
_streamFadeAppendText is defined but never called
A grep across static/ finds exactly one occurrence of _streamFadeAppendText — the definition itself. The test also explicitly asserts it is absent from the rendering path ("_streamFadeAppendText(assistantBody,delta)" not in render_block). The ui.js counterpart _appendTransparentFadeText (called from _refreshTransparentFadeProseRow) already covers the transparent-anchor use case, and _streamFadeRenderer's inline add_text handler covers the messages.js path. As written, this function is dead code and may mislead a future contributor into calling it on assistantBody in transparent mode — which the tests explicitly prohibit.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
|
||
| function _refreshTransparentFadeProseRow(existing, node, preservedState){ | ||
| let body = existing.querySelector ? existing.querySelector('.msg-body') : null; | ||
| const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || '')); |
There was a problem hiding this comment.
rawText carries markdown source but is appended as literal plain text
node.dataset.rawText is set by _anchorProseIncrementalNode to the raw input text passed in (i.e. _streamFadeDomText, which is a slice of displayText and can contain markdown like **bold**). When this cache-reset path fires — triggered by the edit-desync guard (!value.startsWith(st.writtenText)) or a mid-stream _shouldUseLiveProseFade() flip — nextText becomes the raw markdown source string and _appendTransparentFadeText(body, nextText) appends it word-by-word as literal text nodes (not markdown-rendered). Responses with inline markdown would briefly show the raw syntax (**bold** text) until stream completion calls renderMessages(). In normal streaming existing === node and this function is bypassed entirely, so the bug only surfaces in those uncommon reset scenarios. Is the intent that _refreshTransparentFadeProseRow only ever receives plain-text (no markdown) prose content, making node.textContent always equivalent to rawText? If transparent-stream prose can contain markdown, the fallback from markdown-rendered HTML to plain-text appending would need either a rendered delta from the SMD parser or a reset to the new candidate node's full content.
Release: restore prose-level fade for Transparent Stream (#5506)
Ships #5506 (@rodboev, #5367): re-adds the per-word prose fade-in for newly-streamed assistant text in Transparent Stream mode — without reintroducing the row-level entrance flicker that was removed to fix #5367. The fade is prose-word-level only; thinking rows, tool rows, and transparent event rows are not re-animated.
Design decision (Nathan)
The off-by-default "Fade text effect" toggle does not gate the transparent-stream fade — the fade is part of the Transparent Stream experience (a mode you deliberately turn on). We evaluated coupling the toggle to it (Option B) but it fought the settings-persistence architecture (the full merged settings dict round-trips on every save, so "never-configured" leaks at every layer — the fade would have silently defaulted OFF for all users). Chose the simpler, correct-for-intent path: transparent fade always on, settings description clarified.
Gate (clean)
node --check-p no:xdist)transparent-event-enterrefs (flicker invariant intact)Invariants verified by code read: reduced-motion gated at both
_shouldUseLiveProseFade(JS) and@media (prefers-reduced-motion)(CSS); fade animates opacity only on new words; coexists with the shipped #5400/#5454 transparent-stream work. Nathan approved (motion GIF waived given gate + code confidence). Attribution:Co-authored-by: Rod Boev+ CHANGELOG credit.