Skip to content

fix(stream): stop fade-text effect from re-fading the whole message on tool-call rewinds - #6783

Merged
nesquena-hermes merged 4 commits into
nesquena:masterfrom
silent-reader-cn:fix/fade-text-rewind-flash
Aug 8, 2026
Merged

nesquena-hermes merged 4 commits into
nesquena:masterfrom
silent-reader-cn:fix/fade-text-rewind-flash

Conversation

@silent-reader-cn

Copy link
Copy Markdown
Contributor

Summary

With the fade-text effect setting enabled, Hermes long tasks visibly blink/flash the entire message on every tool call: all visible words fade in, disappear, then fade in again.

Root cause

Tool-call XML (<function_calls>…</function_calls>) appears inline in the assistant text stream. _stripXmlToolCalls strips the tail of an unclosed <function_calls opening tag (regex [\s\S]*$), which temporarily rewinds displayText to a strict prefix of what was already shown. That rewind triggered two cascading rebuilds:

  1. _streamFadeNextText detected the prefix mismatch and reset the playout cursor to zero.
  2. _smdWrite's self-heal then saw the new text no longer starting with the written text, cleared assistantBody.innerHTML='', and re-created every word as a fresh is-new span — replaying the fade animation on all visible text at once.

Every tool call in a long task hit this, producing the visible flashing loop.

Fix

  • _streamFadeNextText: on a detected rewind, shrink the playout cursor to the common prefix instead of resetting to zero. The DOM syncs to the shrunken prefix this frame (dropping the rewind tail).
  • _smdWrite + _anchorProseIncrementalNode: when a rebuild is unavoidable, compute the common prefix and set a _streamFadeSilentPrefixChars window.
  • _streamFadeRenderer.add_text + _streamFadeAppendText: words inside the silent-prefix window are written as plain text nodes (no animation replayed); only the post-rewind tail fades in.

Result: already-visible text stays put on tool-call boundaries; only genuinely new words animate.

Tests

Added 3 regression tests in tests/test_smooth_text_fade.py:

  • playout rewind keeps the common prefix visible (no reset-to-zero)
  • rebuild mutes the common prefix (only tail animates)
  • _smdWrite self-heal sets the silent-prefix window on rewind

Verified: tests/test_smooth_text_fade.py + test_smd_media_in_stream.py + test_anchor_prose_incremental_finalize.py + test_issue3397_transparent_stream_prose_segments.py — 62 passed. Full suite: 582 passed, 1 pre-existing Windows permission-test failure unrelated to this change.

…ol-call rewinds

When the fade-text effect is enabled, Hermes long tasks visibly blink the
entire message on every tool call. Root cause: tool-call XML
(<function_calls>) appears inline in the assistant text stream, and the
unclosed-tag stripping in _stripXmlToolCalls temporarily rewinds
displayText to a strict prefix. That rewind zeroed the fade playout
(_streamFadeNextText) and tripped _smdWrite's self-heal, which cleared
assistantBody.innerHTML and re-created every word as a fresh is-new
span — replaying the fade on all visible text at once.

Fix: on a detected rewind, shrink the playout cursor to the common
prefix instead of resetting to zero, and mute the fade renderer for
that common prefix during any rebuild (both the main smd path and the
anchor prose incremental node) so only the post-rewind tail animates.

Adds regression tests: playout rewind keeps common prefix visible,
rebuild mutes the common prefix, and _smdWrite's self-heal sets the
silent-prefix window.
@greptile-apps

greptile-apps Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents streamed assistant messages from replaying their fade animation when tool-call filtering rewinds visible text.

  • Preserves the rendered common prefix when stream playout rewinds.
  • Mutes rebuilt fade spans that correspond to previously visible rendered text.
  • Adds regression coverage for main and anchor prose rendering, markdown/media offsets, and Windows command-length limits.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
static/messages.js Updates stream rewind handling and rendered-prefix muting across the main and anchor prose fade paths.
tests/test_smooth_text_fade.py Adds focused regression coverage for rewind playout, rendered-prefix muting, parser rebuilds, markdown/media offsets, and root isolation.
tests/test_smd_media_in_stream.py Extends the extracted JavaScript test harness with the new fade-prefix state.

Reviews (4): Last reviewed commit: "Merge branch 'master' into fix/fade-text..." | Re-trigger Greptile

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Aug 6, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

Reading all three changed files at PR head 56c9307, plus the corresponding origin/master files, the rewind detection itself is useful. I found one behavior blocker in the new suppression mechanism: the silent-prefix count lives in the raw source-text coordinate space, while both consumers live in rendered-text space. That mismatch means the PR can suppress the first genuinely new words after a rewind.

Code reference

static/messages.js:4350-4366 computes the budget before rebuilding, while renderer add_text receives only rendered text chunks:

_streamFadeSilentPrefixChars=_silentCommon;
let silentLeft=_streamFadeSilentPrefixChars||0;
if(silentLeft>0){
  silentLeft-=match[0].length;
}

The common length includes Markdown delimiters, link destinations, and MEDIA token bytes. Smd does not pass those bytes to add_text: it emits token callbacks or media nodes and passes only visible prose. The same mismatch exists in static/messages.js:4536-4573 and static/messages.js:4864-4889.

Diagnosis / recommendation

A concrete case is old prose alpha beta rewritten to alpha gamma. The source common prefix includes the four strong-emphasis delimiters, but the renderer consumes only alpha and the separating whitespace. The leftover budget then writes gamma as plain text. That contradicts the intended contract that only the already-visible prefix is muted and the changed tail fades. A common prefix containing a MEDIA token has the same problem because the token becomes a node and does not reach add_text at all.
Please scope suppression to the owning parser or renderer, as the existing media-tail state is scoped, and calculate the boundary in rendered-text space. One practical option is to snapshot the old node text before rebuild, rebuild, compare it with the new node text, then remove is-new only from spans covered by that rendered common prefix. Whichever approach is used, clear the state on reduced-motion, safe-renderer, empty-text, and parser-finalization paths. The current stream-global counter can otherwise survive a rebuild that has no fade consumer and affect a later append.

Verification

The new tests at tests/test_smooth_text_fade.py:589-688 use plain text, and the self-heal test stubs parser writing, so they cannot expose this coordinate mismatch. Add a real vendored-smd regression for strong emphasis, links or code, and MEDIA before the changed tail. Assert the old rendered prefix is plain while the first changed word has exactly one stream-fade-word is-new span. Also cover reduced motion during rebuild followed by motion being enabled, and two concurrent anchor/main parsers to prove suppression cannot cross owners. I did not execute PR-authored tests; this review used read-only source, diff, and assertion inspection.

Review feedback (maintainer round on 56c9307): the silent-prefix budget
was computed in SOURCE-text space (charCodeAt over markdown source), but
the fade add_text hook only receives RENDERED prose. Markdown delimiters,
link destinations and MEDIA token bytes never reach add_text, so the
source-space budget over-muted the first genuinely new word after a
rewind (e.g. '**alpha** beta' -> '**alpha** gamma' muted gamma).

Fix: snapshot the OLD rendered text before the rebuild (assistantBody
textContent / anchor .msg-body textContent), then after parser_write
compare it with the NEW rendered text and strip is-new only from spans
inside the rendered common prefix (_streamFadeMuteRenderedPrefix).

- Rendered-space boundary, scoped to the owning node (no stream-global
  counter that can survive a rebuild without a fade consumer)
- No state left behind on reduced-motion / safe-renderer / empty-text /
  parser-finalization paths: _rewindPrevRendered is a local variable
  consumed right after the rebuild
- Tests: plain-word mute, markdown/MEDIA byte regression (the blocker),
  per-root scoping (main vs anchor), updated _smdWrite self-heal test

Gate: node -c OK; test_smooth_text_fade + test_smd_media_in_stream +
test_svg_audio_video_rendering + test_insights -> 83 passed, 18 subtests.
@silent-reader-cn

Copy link
Copy Markdown
Contributor Author

Addressed the coordinate-space blocker — rewind mute now runs in RENDERED-text space.

What changed (commit a6d37b22):

  • Replaced the source-space _streamFadeSilentPrefixChars budget with _streamFadeMuteRenderedPrefix(): snapshot the OLD rendered node text before the rebuild, then after parser_write compare it against the NEW rendered text and strip is-new only from spans inside the rendered common prefix.
  • Markdown delimiters / link destinations / MEDIA token bytes never reach the fade add_text hook, so they no longer inflate the mute budget — **alpha** beta → **alpha** gamma now fades gamma correctly (source-space counted 10 chars and muted it).
  • Scoped to the owning node (main assistantBody and anchor .msg-body handled separately) — no stream-global counter that could survive a rebuild without a fade consumer.
  • _rewindPrevRendered is a local variable consumed right after the rebuild; nothing persists across reduced-motion / safe-renderer / empty-text / parser-finalization paths.

Tests:

  • test_stream_fade_mute_rendered_prefix_plain_words — plain-word prefix mute
  • test_stream_fade_mute_rendered_prefix_markdown_and_media_bytes — the exact blocker regression (source-space budget bug)
  • test_stream_fade_mute_rendered_prefix_scoped_per_root — concurrent main vs anchor nodes stay independent
  • test_stream_fade_smd_write_self_heal_sets_silent_prefix_on_rewind — updated for the rendered-space snapshot flow

Gate: node -c OK; test_smooth_text_fade.py + test_smd_media_in_stream.py + test_svg_audio_video_rendering.py + test_insights.py → 83 passed, 18 subtests.

One note: the strong-emphasis/link/MEDIA-before-changed-tail behavioral test you asked for lives at the unit level (_streamFadeMuteRenderedPrefix with a fake DOM) rather than driving the real vendored smd parser — the full node-driven harness for this renderer doesn't mount a real DOM. Happy to add a vendored-smd round-trip if you want it.

CI lint failure on a6d37b2: tests/test_smooth_text_fade.py:723 F541
f-string without any placeholders. The f-prefix was only used to escape
JS braces ({{ }}) — a plain string with literal braces is equivalent
and passes the ruff E9+F+B gate.
@silent-reader-cn

Copy link
Copy Markdown
Contributor Author

CI lint on a6d37b2 flagged tests/test_smooth_text_fade.py:723 F541 (f-string without any placeholders) — the f""" prefix in _fade_fake_node was only there to escape JS braces. Fixed in commit a72c661b by switching to a plain string with literal braces (equivalent output, passes the ruff E9+F+B gate).

Verified locally: ruff_lint.py --diff origin/master → 0 findings; test_smooth_text_fade.py → 21 passed; test_smd_media_in_stream.py → 31 passed (18 subtests).

@nesquena-hermes nesquena-hermes added size:L Large PR (>10 files or >250 LOC) and removed size:M Medium PR (≤10 files, ≤250 LOC) labels Aug 7, 2026
@nesquena-hermes
nesquena-hermes enabled auto-merge (squash) August 8, 2026 23:34
@nesquena-hermes
nesquena-hermes merged commit cabb467 into nesquena:master Aug 8, 2026
23 checks passed
nesquena-hermes added a commit that referenced this pull request Aug 8, 2026
…ind (#6783, @silent-reader-cn) (#6866)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in exp-v0.52.186 🎉 Thanks @silent-reader-cn.

Gate at the rebased head: Codex SAFE TO SHIP — verified both rebuild paths (the anchor-prose _smdWrite and the _anchorProseSmdCache path) use a node-scoped RENDERED-text prefix compare (not source-byte), so markdown/link/MEDIA bytes can't over-mute the first genuinely-new word; normal append, reduce-motion, and URL-scheme filtering are all intact; the rewind work is O(prefix). Full 5-shard suite green (bar the 2 known box-baseline env flakes); focused 52/52 including the rewind_remount_mutes_common_prefix and rewind_keeps_common_prefix_visible behavioral oracles. Clean fix for a genuinely annoying full-message blink on tool calls.

alai04 pushed a commit to alai04/hermes-webui that referenced this pull request Aug 31, 2026
…n tool-call rewinds (nesquena#6783)

* fix(stream): stop fade-text effect from re-fading whole message on tool-call rewinds

When the fade-text effect is enabled, Hermes long tasks visibly blink the
entire message on every tool call. Root cause: tool-call XML
(<function_calls>) appears inline in the assistant text stream, and the
unclosed-tag stripping in _stripXmlToolCalls temporarily rewinds
displayText to a strict prefix. That rewind zeroed the fade playout
(_streamFadeNextText) and tripped _smdWrite's self-heal, which cleared
assistantBody.innerHTML and re-created every word as a fresh is-new
span — replaying the fade on all visible text at once.

Fix: on a detected rewind, shrink the playout cursor to the common
prefix instead of resetting to zero, and mute the fade renderer for
that common prefix during any rebuild (both the main smd path and the
anchor prose incremental node) so only the post-rewind tail animates.

Adds regression tests: playout rewind keeps common prefix visible,
rebuild mutes the common prefix, and _smdWrite's self-heal sets the
silent-prefix window.

* fix(nesquena#6783): compute rewind mute prefix in rendered-text space

Review feedback (maintainer round on 56c9307): the silent-prefix budget
was computed in SOURCE-text space (charCodeAt over markdown source), but
the fade add_text hook only receives RENDERED prose. Markdown delimiters,
link destinations and MEDIA token bytes never reach add_text, so the
source-space budget over-muted the first genuinely new word after a
rewind (e.g. '**alpha** beta' -> '**alpha** gamma' muted gamma).

Fix: snapshot the OLD rendered text before the rebuild (assistantBody
textContent / anchor .msg-body textContent), then after parser_write
compare it with the NEW rendered text and strip is-new only from spans
inside the rendered common prefix (_streamFadeMuteRenderedPrefix).

- Rendered-space boundary, scoped to the owning node (no stream-global
  counter that can survive a rebuild without a fade consumer)
- No state left behind on reduced-motion / safe-renderer / empty-text /
  parser-finalization paths: _rewindPrevRendered is a local variable
  consumed right after the rebuild
- Tests: plain-word mute, markdown/MEDIA byte regression (the blocker),
  per-root scoping (main vs anchor), updated _smdWrite self-heal test

Gate: node -c OK; test_smooth_text_fade + test_smd_media_in_stream +
test_svg_audio_video_rendering + test_insights -> 83 passed, 18 subtests.

* fix(nesquena#6783): drop f-prefix from _fade_fake_node JS block (F541)

CI lint failure on a6d37b2: tests/test_smooth_text_fade.py:723 F541
f-string without any placeholders. The f-prefix was only used to escape
JS braces ({{ }}) — a plain string with literal braces is equivalent
and passes the ruff E9+F+B gate.

---------

Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
alai04 pushed a commit to alai04/hermes-webui that referenced this pull request Aug 31, 2026
…ind (nesquena#6783, @silent-reader-cn) (nesquena#6866)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
…n tool-call rewinds (nesquena#6783)

* fix(stream): stop fade-text effect from re-fading whole message on tool-call rewinds

When the fade-text effect is enabled, Hermes long tasks visibly blink the
entire message on every tool call. Root cause: tool-call XML
(<function_calls>) appears inline in the assistant text stream, and the
unclosed-tag stripping in _stripXmlToolCalls temporarily rewinds
displayText to a strict prefix. That rewind zeroed the fade playout
(_streamFadeNextText) and tripped _smdWrite's self-heal, which cleared
assistantBody.innerHTML and re-created every word as a fresh is-new
span — replaying the fade on all visible text at once.

Fix: on a detected rewind, shrink the playout cursor to the common
prefix instead of resetting to zero, and mute the fade renderer for
that common prefix during any rebuild (both the main smd path and the
anchor prose incremental node) so only the post-rewind tail animates.

Adds regression tests: playout rewind keeps common prefix visible,
rebuild mutes the common prefix, and _smdWrite's self-heal sets the
silent-prefix window.

* fix(nesquena#6783): compute rewind mute prefix in rendered-text space

Review feedback (maintainer round on 56c9307): the silent-prefix budget
was computed in SOURCE-text space (charCodeAt over markdown source), but
the fade add_text hook only receives RENDERED prose. Markdown delimiters,
link destinations and MEDIA token bytes never reach add_text, so the
source-space budget over-muted the first genuinely new word after a
rewind (e.g. '**alpha** beta' -> '**alpha** gamma' muted gamma).

Fix: snapshot the OLD rendered text before the rebuild (assistantBody
textContent / anchor .msg-body textContent), then after parser_write
compare it with the NEW rendered text and strip is-new only from spans
inside the rendered common prefix (_streamFadeMuteRenderedPrefix).

- Rendered-space boundary, scoped to the owning node (no stream-global
  counter that can survive a rebuild without a fade consumer)
- No state left behind on reduced-motion / safe-renderer / empty-text /
  parser-finalization paths: _rewindPrevRendered is a local variable
  consumed right after the rebuild
- Tests: plain-word mute, markdown/MEDIA byte regression (the blocker),
  per-root scoping (main vs anchor), updated _smdWrite self-heal test

Gate: node -c OK; test_smooth_text_fade + test_smd_media_in_stream +
test_svg_audio_video_rendering + test_insights -> 83 passed, 18 subtests.

* fix(nesquena#6783): drop f-prefix from _fade_fake_node JS block (F541)

CI lint failure on a6d37b2: tests/test_smooth_text_fade.py:723 F541
f-string without any placeholders. The f-prefix was only used to escape
JS braces ({{ }}) — a plain string with literal braces is equivalent
and passes the ruff E9+F+B gate.

---------

Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
…ind (nesquena#6783, @silent-reader-cn) (nesquena#6866)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants