#7310/#7625: the cancel HTTP fallback and settle restore now request the
bounded tail (msg_limit=30) instead of the bare full-transcript shape.
test_cancelled_turn_status and test_live_to_final_anchor_visible_order
assert the exact URL string inside the extracted cancel/settle blocks; the
assertions' intent (embedded SSE payload applied BEFORE the HTTP fallback;
fallback fetch precedes fallback apply) is unchanged, so the pinned
strings are updated to the new bounded shape and the ordering contracts
are untouched.
Also moves the settle-path truncation-signal restore after the
localStorage/update-url block so the 1800-char proximity window in
test_session_rotate_url_sync keeps containing the tab-state sync lines.
Fixes the #7310 stall class: six frontend recovery paths (offline/bfcache
refreshSession, stream-end settle, cancel sync, /compress preflight, /retry,
/undo) issued bare GET /api/session reads that re-walked, re-redacted and
re-serialized the ENTIRE transcript (4-15s / 28MB on a 5k-row session,
#7625), and the same shape shipped the full session-level tool_calls list
because windowing was gated on msg_limit being present.
Changes:
- Server: add explicit msg_limit=all escape hatch; window session-level
tool_calls by actual truncation (length comparison) instead of the
presence of a limit parameter. The bare no-limit shape keeps its
full-transcript contract (pinned by
test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide
and the run-journal snapshot test).
- Client: refreshSession / _restoreSettledSession / cancel sync /
/compress preflight / /retry / /undo now request the bounded tail
(msg_limit=30) and restore the _messages_truncated/_messages_offset paging
gate instead of hardcoding 'not truncated'; jump-to-start
(_ensureAllMessagesLoaded) and outline jump opt in to the full transcript
via msg_limit=all.
Tests: test_session_default_window.py covers helper semantics, per-call-site
bounded/all shapes, truncation-signal restoration, and a regression guard
that no bare GET /api/session fetch remains in static/. 454 related tests
pass; reverse-verified (reverting the fixes turns the tests red).
* perf(composer): make the single-row autoResize fast path reachable
autoResize() skips the height:'auto' -> scrollHeight -> restore round trip only
when the composer is already at its one-row height, but the guard compared
el.offsetHeight against the CSS min-height (44px) while the composer's natural
one-row border box is line-height + vertical padding + borders (~48px at the
stock font). 48 <= ceil(44)+1 is false, so the skip was unreachable in a real
browser: every append keystroke wrote style.height twice, and the scrollHeight
read that follows forces a synchronous layout of the whole document, transcript
included. The cost therefore grows with the rendered transcript, which is the
"typing gets laggy in long sessions" report.
Accept the natural one-row height as the skip ceiling, computed from the same
already-read getComputedStyle. The strict finite-pixel gate is kept for every
value involved, so a percentage / calc / normal line-height, padding or
min-height still fails closed to the full resize (the #6349 re-gate property is
preserved, and test_issue5514_composer_grow_scroll_pin.py still pins it).
Measured on a 758-message / 5 MB session (31k DOM nodes rendered) with the
browser Event Timing API (input -> next paint) at 6x CPU throttle: a keystroke
went from 168ms to ~136ms median echo latency (72ms with the transcript
detached), 96ms to ~72ms in the windowed state, and the style writes per 8
keystrokes from 16 to 2.
The #5514 invariant holds: an oversized composer still takes the full resize
because its offsetHeight is far above the one-row ceiling.
Tests:
- new tests/test_long_session_composer_typing_latency.py runs the real
autoResize() body from messages.js in a node sandbox whose getComputedStyle
returns the composer's computed styles; it is red before this change
(2 failed, 27 passed) and green after (29 passed).
- tests/test_issue5514_composer_grow_scroll_pin.py source pins updated to the
new guard shape.
* test(docs): address #7604 review - behavior-only tests + composer sizing docs
Review feedback on the PR flagged two repo-rule items: tests that pin source
strings, and an undocumented runtime behavior change. Both are addressed here.
- tests/test_long_session_composer_typing_latency.py: delete
test_fix_is_pinned_in_source. It asserted exact JS declarations, which the
repo testing guidance rejects and which the harness in this module already
covers by executing the REAL autoResize() body. In its place add
test_natural_row_skip_survives_every_append_of_a_typed_word, which drives the
actual typing loop ('' -> 'h' -> ... -> 'hello') and asserts zero height
writes on every keystroke. Red on the pre-fix tree, green with the fix.
- tests/test_issue5514_composer_grow_scroll_pin.py: convert
test_single_line_growth_skips_the_height_round_trip from source-string pins to
a behavior fixture whose getComputedStyle returns the composer's real computed
styles. It asserts the observable outcome: a one-row append at the natural
48px row takes no height write, while an oversized 176px composer still
remeasures back to one row. Red before the fix (2 writes), green after.
- docs/UIUX-GUIDE.md: document the composer sizing invariants in the composer
section - the single-row skip is a hot path and must stay reachable, its
ceiling is the natural one-row height or the CSS min-height (whichever is
larger), everything else still remeasures, and non-px computed values fail
closed.
./scripts/test.sh on the two touched modules: 29 passed (3 failed on the
pre-fix tree). Neighbour sweep over the 51 composer/scroll/typing modules:
386 passed. ruff check clean.
* fix(tests,docs): derive composer test dimensions from style.css (#7604 review)
Greptile's re-review is right: the fixtures hardcoded the 18px
`data-font-size=large` composer (line-height 29.7px -> a 47.7px one-row box) as
if it were the stylesheet's 16px default (44.4px). At the default the pre-fix
ceiling (ceil(44)+1 = 45px) DID clear the one-row box, so the regression is
font-size dependent - the 16px default reaches the skip, the 18px and 20px
appearance settings do not - and not a stock-configuration bug. The runtime
change is unaffected and unchanged.
- new `tests/_composer_metrics.py`: parses `static/style.css` (the base
`textarea#msg` rule, the `data-font-size` overrides, the skin overrides) and
exposes each configuration's box metrics - natural one-row height, the integer
offsetHeight a browser reports, and both the pre-fix and post-fix ceilings -
so a fixture cannot encode the wrong font size again.
- `tests/test_long_session_composer_typing_latency.py`: parameterized over every
configuration the stylesheet can produce, with
`test_the_regression_was_font_size_dependent_not_stock` pinning the grounding.
- `tests/test_issue5514_composer_grow_scroll_pin.py`: the converted single-row
test uses the same derived metrics.
- `docs/UIUX-GUIDE.md`: the sizing section now states that the natural row
tracks the appearance font size (44 / 48 / 51px) while `min-height` stays
44px, which is why a min-height-only ceiling silently disables the skip.
Pre-fix tree: `4 failed, 31 passed` - exactly the large-18px and xlarge-20px
parameterizations plus the two tests that assert the reported configuration.
Post-fix tree: `35 passed`. Neighbour sweep over the 51 composer/scroll/typing
modules: `392 passed`. ruff clean.
Use native CSS field-sizing for composer auto-grow where supported, keeping the JS path as fallback. Includes a fallback-parity fix so an empty composer holds its resting height instead of measuring the placeholder.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
routes.py appends a 'live anchor shell' row whenever a stream has events
but no visible rows projected yet:
row_id: lifecycle:<stream>:running
role: lifecycle
status: running
text: Working
source_event_type: runtime_journal_snapshot
_sourceEventTypeForSnapshotAnchorRow skips the source_event_type branch
for exactly that value, falls through to the role check, and the bare
phase==='running' arm then classified the shell as 'compressing'. Every
session that hit the path painted a 'Compressing context' divider while
nothing was compressing - including brand-new sessions on their first
message.
It also persists. The classification is written back into the session's
anchor_activity_scenes as source_event_type: 'compressing', which the
source branch above then returns directly on the next load, so the
phantom divider survives a reload. Measured on this host: 46 such rows
across 24 sessions, every one carrying the text 'Working' and not one a
real compression notice.
Require an explicit cue instead - phase==='compressing' or one of the
compression text markers already listed below it. The function's own
comment asked for this ('do not default every lifecycle row to a running
compress divider'); the bare running arm did the opposite.
tests/test_compression_phantom_barrier.py: 8 passed.
Review follow-ups for #7082:
- Maintainer should-fix: the no-cursor rebuild branch of
_refreshTransparentFadeProseRow cleared the body and re-wrapped every
word as .is-new, so the whole visible row dipped to opacity 0 and
faded back (~620ms), and wholesale node replacement invited the
one-time scroll-anchor bounce (#6257). Now the rendered text is
snapshotted before the clear and the messages.js
_streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
so only genuinely-new tail words animate. The helper is exported on
window (__streamFadeMuteRenderedPrefix) the same way
__anchorProseIncrementalNode is.
- Greptile P1: the same branch passed raw source text to
_appendTransparentFadeText, flattening live markdown rows to literal
syntax (links/emphasis/headings lost until settlement). The rebuild
now adopts a deep clone of the candidate's parsed .msg-body (the
incremental streaming-markdown output) when one exists, so the parsed
DOM survives; the plain-text rebuild remains only as fallback for
candidates without a parsed body. The resume cursor stays in source
space when rendered text diverges from source (markdown), and shrinks
to the rendered prefix for plain prose so the parser's held-back
character is re-appended on the next delta instead of dropped.
Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
Replace .trim() with replace(/^\s+/, '') in _stripXmlToolCalls and
_stripXmlToolCallsDisplay to preserve trailing whitespace/characters
that appear before <function_calls> blocks in the streaming text.
Fixes: streaming text losing the last character before a tool call card.
The WebUI clarify bridge resolved the prompt timeout from a local, divergent
copy (legacy clarify.timeout, 120s fallback) and treated 0 as invalid, so
agent.clarify_timeout: 0 (unlimited) could not be honored and there was no
way to make a clarification card wait for the user.
Resolve the timeout with the agent core's canonical resolve_clarify_timeout()
(clarify.timeout -> agent.clarify_timeout -> 3600, <= 0 = unlimited) against
the session's own profile config (the streaming worker thread does not inherit
the request's per-profile context), wait with a null deadline when unlimited,
and stop the frontend rendering an expired countdown for non-positive timeouts.
- api/streaming.py: per-session profile config resolution, canonical resolver
with a compatibility fallback, and an extracted _await_clarify_response()
helper that polls in 1s slices and honours cancellation.
- api/clarify.py: preserve timeout_seconds <= 0 and emit no expires_at.
- static/messages.js: _clarifyExpiryMs() returns 0 for non-positive timeouts
so the card stays up without a countdown.
- tests/test_sprint42.py: resolution matrix, per-session profile divergence,
unlimited resolve-later / cancel-exit, and no-expiry payload.
Closes#7159
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(ui): prevent OOM on tab focus for long sessions
Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.
Closes#6999
* fix(ui): full turn context + collision-free cache hash (#7006 review rework)
Addresses the maintainer's CHANGES_REQUESTED on PR #7006:
- Turn-context maps (_assistantTurnFinalVisibleContentMap /
_assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
The virtualized render window (head+tail around a gap) cut through
assistant runs and merged distinct turns across an omitted user
boundary, which could duplicate the final answer in Worklog/Thinking
or feed later-turn prose into the reasoning echo-strip. Turn context
is input, not output: it must always be complete.
- _addBoundedHash now streams the FULL string form through the FNV-1a
loop (no length+head+tail clip, no slice copies), and object fields
(attachments, tc.args, compression-anchor keys) are walked key-by-key
so no integral JSON.stringify() is allocated. Same-length middle-only
edits of tool args, attachment metadata, tool snippets, or
compression-anchor keys no longer collide in _sessionHtmlCache.
Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
with a virtualized gap proves full-context echo-strip suppresses the
exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
the real _messageRenderCacheSignature() proves same-length middle-only
mutations change the signature for every structured field while the
old clip scheme collides on the same data.
Closes#6999
* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash
Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
are latched per SID (max announced count) and drained by the refresh
owner's finally with ONE guarded follow-up, instead of being dropped
(production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
render projection) with an explicit per-key index discriminator, so
opposite-insertion-order argument objects no longer share a cache
signature; recursion depth is now threaded (children increment, not
restart).
* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness
#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text.
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
When the user switches from a streaming session (A) to another session (B),
A's streaming text could render in B's window because:
1. The token handler guards against rendering when the session is not active,
but the rAF/setTimeout window between _scheduleRender() and _doRender()
can outlive a session switch.
2. _doRender() writes to assistantBody without checking whether the stream's
session is still the active frontend pane.
Fix: add _isActiveSession() guards to:
- _scheduleRender() — drop scheduled renders when the session changed
- _doRender() — bail before any DOM write if the session switched
- _flushPendingSegmentRender() — defense-in-depth for any future callers
These guard at the same level the token/interim_assistant event handlers
already do, closing the rAF gap that could leak streamed text into a
different session's view.
Co-authored-by: webtecnica <contato@webtecnica.com.br>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(stream): preserve fade nodes while prose is live
* fix(stream): preserve transparent fade nodes like assistant prose
The transparent-activity prose path (transparent_stream mode) kept the
pre-PR fade cleanup: _bindTransparentFadeCleanup() in static/ui.js still
replaced every animated stream-fade-word span with a fresh text node on
animationend, invalidating the native scroll anchor while the row is
still growing.
Mirror the messages.js fix so both live prose paths share one
node-preserving cleanup: remove only the transient is-new class and the
--stream-fade-ms inline property, keeping span identity until final
settlement rebuilds the persisted DOM.
Extend tests/test_smooth_text_fade.py with a sibling regression that
extracts the production _bindTransparentFadeCleanup() from ui.js and
fires animationend, asserting no replacement, is-new removal,
stream-fade-word retention, and timing-override cleanup (plus
source-string guards against replaceWith in the cleanup block). The new
test is red-before-green against the previous ui.js implementation.
* docs(changelog): stamp #6257 (stream fade scroll-bounce fix)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Ships PR #6410 with a maintainer UX fix folded in (authorship preserved).
Adds a 'Refine' action to the message text-selection toolbar: select text in a
chat message -> Refine seeds it into the composer as an editable quoted draft
(marker-free, one-way, existing draft + attachments preserved, 44x44 mobile
icon buttons, all 15 locales). ChatGPT/Notion-style quote-to-edit flow.
Maintainer fix (Fable UX gate): French instruction string now uses the
space-before-colon convention (Instruction d'affinage :).
Gate: Codex SAFE TO SHIP (marker-free, no XSS, one-way Range, Reply/drafts
preserved), Fable SHIP-WITH-UX-FIXES (fix applied), tests 19/19, ESLint clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* 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(#6783): compute rewind mute prefix in rendered-text space
Review feedback (maintainer round on 56c9307f): 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(#6783): drop f-prefix from _fade_fake_node JS block (F541)
CI lint failure on a6d37b22: 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>
* fix: clear stale compression barrier on turn-done when compressed SSE is lost
When a turn completes (done SSE event) but the "compressed" SSE event was
lost or delayed, the compression UI state remained in phase="running".
The done handler unconditionally rebound the stale state sessionId to
the continuation session, surfacing a phantom "Compressing context"
barrier on a session that never actually compressed.
Fix: when the done event session_id differs from the active session AND
the compression UI is still in "running" phase, clear it via
clearCompressionUi() instead of rebinding. Non-running states (e.g.
"done") are still rebound as before.
Added regression tests verifying:
- stale running state is cleared (not rebound) on session change
- non-running states are still rebound
- the old unconditional rebind is gone
* fix: also clear phantom compression on same-session done (A->A case)
Reviewer feedback on PR #6572: the previous fix only cleared stale running
compression when the session ID changed (A->B). But the backend also supports
compression without session rotation (A->A), where the compressed SSE being
lost leaves the same phantom barrier.
Fix: in the done handler, clear any owned automatic compression state that is
still in "running" phase, regardless of whether the session ID changed. The
condition is now phase==='running' alone, not session_id change AND running.
Added behavioral state-transition tests for all four cases:
- running A->B (rotation, compressed lost): cleared
- running A->A (no rotation, compressed lost): cleared
- non-running A->B (rotation, compressed seen): rebound
- non-running A->A (no rotation, compressed seen): rebound
* test: real SSE regression for compression phantom barrier
Rewrite test_compression_phantom_barrier.py to use real browser + SSE
instead of handwritten Python simulation. The new tests:
- Boot real WebUI server with isolated state
- Use deterministic mock gateway (Runs API mode)
- Verify done handler clears stale running compression state
- Cover both A→B (rotation) and A→A (no rotation) cases
Key insight: /v1/capabilities must return run_approval_response=true
to enable the Runs API path in gateway_chat.py.
Fixes reviewer feedback: simulation never called attachLiveStream,
clearCompressionUi, or registered done listeners.
* test: replace Playwright test with hermetic source-grep suite for compression phantom barrier
The previous Playwright-based test failed CI in no-egress sandboxes due to
external resource errors (net::ERR_INTERNET_DISCONNECTED) and was structurally
unsound: the mock window.S shadowed the lexical S, so the compressing handler
short-circuited before the barrier could be established, producing a false-
positive assertion.
New suite uses static source analysis (same convention as test_issue840,
test_issue3040, test_bg_task_complete_focus_drop):
- No browser, no network, no playwright dependency
- Proves the done handler clears phase:'running' without gating on session ID
change (A->A case coverage)
- Asserts gating conditions: automatic && owner && done session exists
- Negative controls: non-owner / manual compression are ignored
- Source-level regression proof: reverting the production hunk breaks tests
13 tests. All pass in <3s on bare Python.
* test: exercise compression cleanup through real SSE handlers
Replace source-grep assertions with a hermetic Playwright harness that loads the shipping classic scripts and dispatches MockEventSource events through attachLiveStream. Cover A-to-A/A-to-B cleanup, non-running rebind, ownership controls, canonical cleanup side effects, and a production-branch mutation proof.
---------
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
Codex re-gate found the single-row resize fast path used parseFloat on
getComputedStyle(el).minHeight, which reads a bogus pixel value for a
non-px unit (parseFloat('50%')===50) and would wrongly enable the fast
path, leaving an oversized composer stuck tall. Parse with a strict
/^<number>px$/ test so %, auto, empty, and calc() fail closed to the
full resize. Adds red-before-green regression coverage across units.
Co-authored-by: sheldon-im <sheldon-im@users.noreply.github.com>
Fable UX polish pass on the gateway approval counter:
- Move the crude inline style (font-size:0.75em;opacity:0.6;margin-top:4px) into
a proper .approval-counter rule: 11px, color:var(--muted) (skin-safe vs opacity),
margin:0 0 12px (fixes the 0px gap that left it flush against the buttons),
tabular-nums for stable digits.
- i18n the '1 of N pending' string (was the only hardcoded-English string in an
otherwise fully-localized approval card): new approval_pending_count key in en
+ 14 locales, wired via t() with an English fallback.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Anchor-owned process-prose rows kept the newest character as streaming-markdown
lookahead until the turn settled, so Compact Worklog prose immediately before a
tool card appeared one character short mid-stream. Finalize the cached parser
once when the row becomes 'completed' (running the same media/link/table cleanup
as the main body parser), guarded by a 'finalized' flag + self-heal on edit.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Issue #6385: when a streaming turn settles, the two-render sequence
(keep-open expanded worklog → collapsed worklog) could displace the
reader's viewport because the second render captured its scroll snapshot
from the intermediate expanded state, not from the original live DOM.
Root cause
----------
The STREAM_DONE handler in messages.js:
1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true})
→ worklog rendered EXPANDED (height-stable swap preventing shrink jump)
2. Disarms token → _renderMessagesWithScrollSnapshot()
→ This function called _captureMessageScrollSnapshot() which captured
the scroll anchor from the expanded-worklog DOM (step 1 output),
then called renderMessages with the worklog COLLAPSED (keep-open gone),
then tried to restore from the expanded-state snapshot.
The snapshot's semantic anchor (row key, session idx, top offset) was
captured from a DOM where the worklog was expanded. After the collapse
render the worklog is no longer at that position — anchor keys don't
match, the semantic restore fails, and the viewport jumps to a
unrelated scrollTop.
Fix
---
- Capture the scroll snapshot from the LIVE DOM (before any settlement
renders) and pass it as to the second render.
- Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured
snapshot via options._prescrollSnapshot, using it instead of capturing
a fresh one from the current DOM state.
This ensures the collapse render anchors to the content the reader was
actually viewing, not to a stale intermediate expanded-worklog position.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>