From cd5a6de2317b54e84be23fd7780ceccfb79c7e26 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 14 Jun 2026 02:08:45 +0000 Subject: [PATCH] fix(transparent-stream): keep live prose chronological, don't fold into top Worklog rail (#4096) In Transparent Stream mode a live multi-round turn (narrate -> tools -> narrate -> tools) rendered all assistant prose bunched at the top with every tool/thinking row clustered below; it self-healed only once the turn settled. _syncLiveWorklogReasonsForAnchor() runs on every live segment render (_flushPendingSegmentRender + the RAF _doRender in messages.js) and unconditionally built the top-anchored live-worklog rail, mirrored each round's prose into a wl-reason row there, and hid the real inline assistant-segment via assistant-segment-worklog-source (display:none). That prose-folding is the Compact Worklog presentation (#3401) and must not run in Transparent Stream mode. Gate the helper on isCompactWorklogMode() so transparent mode keeps prose as visible, chronologically-placed inline segments interleaved with tool rows (matching the already-correct settled render). Compact Worklog folding is unchanged. Verified in an isolated mock-LLM env via an on-screen visual-position probe (before: textAfterFirstTool=0, allTextAboveAllTools=true; after: textAfterFirstTool=3, allTextAboveAllTools=false) and confirmed compact_worklog still folds. Adds a behavioral regression test asserting the gate is the first statement in the helper (precedes the rail build), red/green validated. --- CHANGELOG.md | 5 ++ static/ui.js | 18 +++++++ ...st_issue3820_chat_activity_display_mode.py | 52 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ccbc0b..4c39b18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +### Fixed + +- **Transparent Stream: assistant prose no longer bunches at the top of a live multi-round turn (#4096).** During a streaming turn that alternates prose with tool calls (narrate → tools → narrate → tools …), every round's prose visually stacked at the top of the turn while all tool/thinking rows clustered below it; the transcript only re-interleaved correctly once the turn settled. Root cause: `_syncLiveWorklogReasonsForAnchor()` runs on every live segment render and unconditionally built the top-anchored Worklog rail — mirroring each round's prose into a `wl-reason` row there and hiding the real, chronologically-placed inline `assistant-segment` (`assistant-segment-worklog-source` → `display:none`). That prose-folding is the **Compact Worklog** presentation (#3401) and must not run in **Transparent Stream** mode, where prose is meant to stay as visible inline segments interleaved with tool rows. The helper is now gated on `isCompactWorklogMode()`, so Transparent Stream keeps prose in chronological position live (matching the already-correct settled render), while Compact Worklog folding is unchanged. (#4096) + + ## [v0.51.404] — 2026-06-14 — Release NQ (PWA multi-window connection-pool saturation fix, #4151) ### Fixed diff --git a/static/ui.js b/static/ui.js index 7a4f27ce..37a7e6e0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7631,6 +7631,17 @@ function _appendWorklogStep(group, anchor, cards, thinkingText, opts){ } } function _syncLiveWorklogReasonsForAnchor(anchor, displayTextOverride){ + // Worklog reason-mirroring (folding intermediate prose into a top Worklog rail + // and hiding the inline `assistant-segment` via `assistant-segment-worklog-source` + // → display:none) is the Compact Worklog presentation (#3401). In Transparent + // Stream mode prose must stay as visible, chronologically-placed inline segments + // interleaved with tool rows — so do NOT build the worklog rail or hide the + // inline segment here. Without this gate every round's prose mirror piles into + // the single top rail while tool rows append at the bottom, so all prose bunches + // above all tools during a live multi-round turn (#4096); it only self-heals when + // the turn settles and renderMessages() rebuilds with the compact-only + // `messageBelongsInWorklog` gate (which is already isCompactWorklogMode()-only). + if(typeof isCompactWorklogMode==='function' && !isCompactWorklogMode()) return; if(!anchor||!anchor.matches||!anchor.matches('[data-live-assistant="1"]')) return; const blocks=anchor.parentElement; if(!blocks) return; @@ -7759,6 +7770,13 @@ function ensureActivityGroup(inner, opts){ function normalizeLiveActivityGroupPlacement(turn){ const blocks=_assistantTurnBlocks(turn); if(!blocks) return; + // Compact Worklog only: this reorders `.tool-call-group`/`.tool-worklog-group` + // containers, which exist solely on the Compact Worklog live path. Transparent + // Stream renders tool rows as flat `.transparent-event-row`s and never builds + // these group containers (see appendLiveToolCard's transparent branch), and the + // worklog prose-rail is gated off in transparent mode (#4096), so the selector + // below matches nothing and this is a no-op there. Kept implicit (empty match) + // rather than an early return so reconnect/restore behavior is unchanged. const groups=Array.from( blocks.querySelectorAll('.tool-worklog-group[data-live-tool-worklog-group="1"],.tool-call-group[data-live-tool-worklog-group="1"],.tool-call-group[data-live-tool-call-group="1"]') ); diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py index 7a9b68e0..7be26627 100644 --- a/tests/test_issue3820_chat_activity_display_mode.py +++ b/tests/test_issue3820_chat_activity_display_mode.py @@ -639,3 +639,55 @@ def test_transparent_entrance_animation_is_live_turn_only(): """The entrance animation must be scoped to the live turn so it doesn't replay across the whole transcript on every renderMessages. (Trifecta V9.)""" assert "#liveAssistantTurn .transparent-event-row{animation:transparent-event-enter" in STYLE_CSS + + +def test_live_worklog_reason_mirror_is_gated_to_compact_mode(): + """#4096: during a live multi-round turn in Transparent Stream mode, all + assistant prose visually bunched at the top while every tool row clustered + below, self-healing only when the turn settled. + + Root cause: _syncLiveWorklogReasonsForAnchor() runs on every live segment + render (from _flushPendingSegmentRender + the RAF _doRender in messages.js). + It builds the top-anchored `live-worklog` rail, mirrors each round's prose + into a `wl-reason` row there, AND tags the real chronological inline + `assistant-segment` as `assistant-segment-worklog-source` (-> display:none, + style.css). That worklog-folding is the Compact Worklog presentation (#3401) + and must NOT run in Transparent Stream mode, where prose stays as visible, + chronologically-placed inline segments interleaved with tool rows. + + The fix gates the whole function on isCompactWorklogMode(). Assert the guard + is the FIRST statement in the function body (before it touches + ensureLiveWorklogContainer / _syncWorklogReasonFromAnchor) so it actually + short-circuits in transparent mode rather than running the rail-build first. + """ + start = UI_JS.index("function _syncLiveWorklogReasonsForAnchor(anchor, displayTextOverride){") + end = UI_JS.index("\nfunction ", start + 1) + body = UI_JS[start:end] + + # The compact-mode gate exists and short-circuits non-compact (transparent) mode. + guard = "if(typeof isCompactWorklogMode==='function' && !isCompactWorklogMode()) return;" + assert guard in body, "missing transparent-mode gate on _syncLiveWorklogReasonsForAnchor" + + # The guard must come BEFORE the rail is built / prose is mirrored, otherwise + # it would not actually prevent the bunching. + guard_idx = body.index(guard) + assert guard_idx < body.index("ensureLiveWorklogContainer("), ( + "compact-mode gate must precede ensureLiveWorklogContainer() so transparent " + "mode never builds the top worklog rail" + ) + assert guard_idx < body.index("_syncWorklogReasonFromAnchor("), ( + "compact-mode gate must precede _syncWorklogReasonFromAnchor() so transparent " + "mode never hides the inline assistant-segment or appends a wl-reason mirror" + ) + + # Both live-render call sites still invoke the (now-gated) helper — the gate + # lives in the helper, not at the call sites, so live rendering is unchanged + # in compact mode. + MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") + assert MESSAGES_JS.count("_syncLiveWorklogReasonsForAnchor(assistantRow") >= 2 + + # The settled-render worklog-folding gate is also compact-only (regression + # guard against the symmetric settled-path bug). + render_message_start = UI_JS.index("const messageBelongsInWorklog=") + render_message_end = UI_JS.index("if(messageBelongsInWorklog)", render_message_start) + assert "isCompactWorklogMode()" in UI_JS[render_message_start:render_message_end]