diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..6e45e26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2450** by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final `done` SSE event so very large or bursty completed responses are rendered from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/static/messages.js b/static/messages.js index 7d40e2f9..0c77e89e 100644 --- a/static/messages.js +++ b/static/messages.js @@ -697,6 +697,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ 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 _streamFadeEnabledForStream=window._fadeTextEffect===true; // rAF-throttled rendering: buffer tokens, render at most once per frame @@ -1086,6 +1087,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ : _stripXmlToolCalls(assistantText.slice(segmentStart)); } function _drainStreamFadeBeforeDone(onDone){ + const drainStartedAt=performance.now(); + let forcedDone=false; const step=()=>{ if(!assistantBody){onDone();return;} const target=_streamFadeCurrentDisplayText(); @@ -1101,6 +1104,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ setTimeout(onDone, Math.min(remainingAnimationMs, _STREAM_FADE_DONE_MAX_MS)); return; } + // Final SSE `done` means the canonical completed session is available. + // The optional word-fade playout must not keep that completed answer + // hidden behind the live Thinking state for large/bursty responses. + if(!forcedDone&&performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS){ + forcedDone=true; + if(_smdParser) _smdEndParser(); + onDone(); + return; + } setTimeout(()=>requestAnimationFrame(step), 33); }; step(); diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index ee5f8ff7..1faad4d6 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -82,6 +82,7 @@ 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 performance={performance_stub}; {helpers} """ @@ -178,6 +179,20 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): assert "_wrapStreamingFadeWords" not in MESSAGES_JS +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_contains_all( + drain_block, + [ + "const drainStartedAt=performance.now();", + "performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS", + "if(_smdParser) _smdEndParser();", + "onDone();", + ], + ) + + def test_stream_fade_css_is_opacity_only_and_hides_live_cursor(): fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :] assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(