Skip to content

Release: restore prose-level fade for Transparent Stream (#5506) - #5652

Merged
nesquena-hermes merged 13 commits into
masterfrom
review-5506
Jul 6, 2026
Merged

nesquena-hermes merged 13 commits into
masterfrom
review-5506

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

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)

Gate Result
ESLint runtime + node --check ✅ CLEAN
Browser smoke (/, /#settings, /#sessions) ✅ CLEAN — 0 console errors
Full pytest suite (-p no:xdist) ✅ 12158 passed, 0 failed
Codex (regression) ✅ SAFE TO SHIP — 0 findings; both round-1 SILENTs fixed (reduced-motion fully off, no hidden-span leak); 0 transparent-event-enter refs (flicker invariant intact)
Fable-UX ✅ SHIP-UX — opacity-only, no layout shift, reduced-motion honored at JS+CSS, cross-device sound

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.

rodboev and others added 13 commits July 4, 2026 10:02
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>
@nesquena-hermes
nesquena-hermes merged commit 8cdb4ea into master Jul 6, 2026
18 checks passed
@nesquena-hermes
nesquena-hermes deleted the review-5506 branch July 6, 2026 00:35
@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This release PR re-introduces per-word prose fade-in for Transparent Stream mode without reintroducing the row-level entrance flicker fixed in #5367. The fade is driven by _anchorProseIncrementalNode + _streamFadeRenderer on the visible anchor-scene rows while the hidden assistantBody receives only plain text; reduced-motion is gated at both JS (_shouldUseLiveProseFade) and CSS (@media (prefers-reduced-motion)).

  • Transparent-stream path in _renderStreamingFadeMarkdown: when _shouldUseTransparentStreamFade() is true, the hidden body receives plain createTextNode appends (no span wrapping), and _streamFadeDomText drives _upsertAnchorProcessProse so the visible anchor row gets the throttled, fade-rendered text via _streamFadeRenderer.
  • _refreshTransparentFadeProseRow in ui.js handles the rare cache-reset case where the anchor-scene reconciler encounters a different node object than what's in the DOM, incrementally appending only the text delta as new fade spans.
  • Animation constants tripled (base 200 → 620 ms, max 350 → 900 ms) and per-word stagger/offset logic removed, giving a softer rolling fade instead of a staggered reveal.

Confidence Score: 4/5

Safe to merge; the new fade path is well-isolated behind _shouldUseTransparentStreamFade(), reduce-motion is honoured at both layers, and the existing === node early-return means the rare _refreshTransparentFadeProseRow branch is not exercised during normal streaming.

Two quality issues were found: _streamFadeAppendText in messages.js is defined and tested but never called from production code (confirmed by grep), and _refreshTransparentFadeProseRow reads rawText (the markdown source string) and appends it via plain-text span logic — a mismatch that would briefly expose raw markdown syntax during cache-reset edge cases. Both are non-blocking and self-correcting at stream completion, but the dead function in particular could mislead a future contributor.

static/messages.js (_streamFadeAppendText) and static/ui.js (_refreshTransparentFadeProseRow) warrant a second look.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "release: restore prose-level fade for Tr..." | Re-trigger Greptile

Comment thread static/messages.js
Comment on lines +4267 to +4299
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _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!

Comment thread static/ui.js

function _refreshTransparentFadeProseRow(existing, node, preservedState){
let body = existing.querySelector ? existing.querySelector('.msg-body') : null;
const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || ''));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model Response Flickering when using Activity Display “Transparent Stream”

2 participants