Skip to content

fix(composer): stop footer fit from jittering the transcript during SSE - #7275

Closed
ruizanthony wants to merge 2 commits into
nesquena:masterfrom
ruizanthony:fix/composer-footer-fit-jitter
Closed

ruizanthony wants to merge 2 commits into
nesquena:masterfrom
ruizanthony:fix/composer-footer-fit-jitter

Conversation

@ruizanthony

Copy link
Copy Markdown
Contributor

Problem

While an SSE response streams, the transcript visibly jitters: it shifts up a few pixels and immediately back down, over and over. It is most noticeable for a reader pinned to the bottom, and it repeats for the entire duration of the stream.

The cause is not in the scroll code. It is in _fitComposerFooter().

To decide whether the composer footer overflows, that function removes the compacting stage classes (.cf-icons / .cf-burger), reads scrollWidth, then puts the classes back:

footer.classList.remove('cf-icons','cf-burger');
const overflow = footer.scrollWidth > footer.clientWidth;   // ← full-width layout
footer.classList.toggle('cf-icons', ...);                    // ← restored

Between those two statements the footer is laid out at full width. It grows by ~8px, #messages loses exactly that much clientHeight, and regains it on the next frame. The browser reflows the transcript twice, and the user sees an 8px bounce.

This fit pass runs on every context-indicator update, so during a stream it runs continuously — which is why the jitter is permanent rather than a one-off.

Fix

Freeze the footer for the duration of the measurement so the intermediate full-width state is never presented:

  • pin the current height explicitly (frozenHeight) so #messages keeps its clientHeight,
  • set visibility:hidden during the probe so the transient layout is never painted,
  • restore both immediately after the classes are re-applied.

The measurement itself is unchanged, so the adaptive behaviour is unchanged.

Evidence

Measured in Chromium with instrumented probes (scrollTop writes, scroll, ResizeObserver, MutationObserver), attributing every non-programmatic backward movement:

before after
non-JS backward jumps per streamed turn 4 (−8px each) 0
  • 5 streamed turns across 2 different conversations: zero unjustified backward movement.
  • The single backward movement initially observed after the fix was verified as legitimate: scrollHeight genuinely shrank (3229 → 3223), so the position follows. It did not reproduce on a second run.

No regression in the adaptive behaviour — the footer stage was verified across 8 viewport widths (1600px → 420px): correct stage selected at each width, and no residual inline height / visibility left on the element afterwards.

Scope

One file, static/ui.js, +35/−5. No change to the scroll/pinning logic, and no dependency on #7268 — this cherry-picks cleanly onto a pristine master.

Note on #7268

This is deliberately a separate PR, not stacked. #7268 fixes a different root cause (browser tail drift silently unpinning readers) in different code. The two are independent and can be reviewed and merged in either order.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

Reading the full one-file diff at 17bcf37bb, the complete static/ui.js at HEAD and origin/master, the footer stage CSS, and the existing mobile-layout regression file, this is a sound fix for the reported geometry oscillation. The probe still evaluates the same full, icon, and burger states, but the footer border box is held at its pre-probe height and hidden until the final stage is restored. I do not see a correctness blocker in this scope.

Code reference

The important boundary is static/ui.js:5068-5099:

const prevVisibility=footer.style.visibility;
const prevHeight=footer.style.height;
const frozenHeight=footer.getBoundingClientRect().height;
if(frozenHeight>0){

The finally at static/ui.js:5092-5099 restores the resolved classes before releasing both inline styles, so an exception in either overflow measurement cannot leave the composer hidden or height-pinned. The stage state itself remains equivalent to the original ladder: no overflow clears both classes, first-stage overflow retains cf-icons, and second-stage overflow retains both cf-icons and cf-burger.

The CSS contract supports the freeze. The global rule at static/style.css:1 uses box-sizing:border-box, so assigning the getBoundingClientRect().height value does not add footer padding a second time. The normal footer layout at static/style.css:2598 has no transition on height or visibility, and the actual compaction rules remain owned by static/style.css:3009-3039; this patch does not duplicate or bypass them.

Diagnosis / recommendation

The implementation fixes the right layer. Changing scroll pinning would only mask the layout source, while this keeps #messages client height stable during the destructive class probe. Preserving prior inline height and visibility also makes the helper composable with any caller-owned style state.

The remaining gap is automated coverage, not a code defect. tests/test_mobile_layout.py:354-367 currently checks only that _fitComposerFooter() contains width measurement and both class names. It would pass if the height/visibility freeze or the finally restoration disappeared.

Verification

A focused browser test should record the messages container clientHeight across one fit pass from each starting stage, assert no intermediate resize notification, and verify that the final stage plus prior inline styles are restored. Keep the existing viewport-width sweep and the reported pinned-tail stream probe. This was read-only worktree inspection; contributor-authored code was not executed.

_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.

For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.

Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:

  before: 4 non-JS backward jumps of exactly -8px per streamed turn,
          each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
          class mutation pair
  after:  0 backward jumps over 5 streamed turns on 2 different sessions

Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.

Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.
@ruizanthony
ruizanthony force-pushed the fix/composer-footer-fit-jitter branch from 17bcf37 to 57a75e8 Compare August 26, 2026 22:26
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

I re-read the complete static/ui.js at current head 57a75e8b2 and on origin/master, the footer stage CSS, and the complete mobile-layout regression file. I also compared the previously reviewed tree at 17bcf37bb with this head; the static/ui.js path diff is empty. The rebase therefore preserves the implementation already reviewed. The height freeze, visibility suppression, stage selection, and finally restoration still form a coherent same-task boundary, and I found no new correctness blocker.

Code reference

The critical ordering remains at static/ui.js:5073-5101:

const frozenHeight=footer.getBoundingClientRect().height;
if(frozenHeight>0){
  footer.style.height=frozenHeight+'px';
  footer.style.visibility='hidden';
}
let next='';
try{

The corresponding finally restores the resolved cf-icons and cf-burger classes before putting the prior inline height and visibility back. That order matters: releasing the frozen box first would reintroduce an observable intermediate stage.

The layout contract still supports this. static/style.css:2598 defines the footer as a flex container with no height transition, while static/style.css:3009-3039 owns the two compact-stage geometries. The patch does not duplicate those rules; it only prevents their temporary removal from changing the messages viewport while overflow is measured.

Diagnosis / recommendation

No additional product-code change is requested from this re-review. The helper preserves prior caller-owned inline styles, skips the freeze when the measured height is zero, and restores state after exceptions. Since _scheduleComposerFooterFit() calls the helper from a queued animation frame (static/ui.js:5105-5112), all destructive class probes still happen inside one callback rather than being exposed across frames.

The remaining test gap from the first review is unchanged. tests/test_mobile_layout.py:354-367 checks only that the helper measures widths and mentions both class names. It does not execute the function or prove that height, visibility, and starting-stage state are restored. A focused DOM behavior test should start from full, icons, and burger stages, force each overflow outcome, and assert the footer border-box height and prior inline styles remain stable through the probe.

Verification

Current exact-head GitHub metadata shows test, lint, browser-smoke, and all three lifecycle checks green. The implementation tree is unchanged from the prior review. No contributor-authored code was executed during this read-only re-review.

Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.

The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Added the behavioural DOM coverage requested in the re-review: tests/test_composer_footer_fit_freeze.py (commit 3d5724a).

It drives the actual _fitComposerFooter() from static/ui.js through the existing node-driver pattern, against a layout model where the stage classes dictate the footer's natural height and the left cluster's content width; every class mutation, inline-style write and overflow measurement commits a layout sample.

Matrix: start stage ∈ {full, icons, burger} × forced outcome ∈ {full, icons, burger} × prior inline styles ∈ {empty, caller-owned height:52px; visibility:visible}. Assertions:

  • resolved cf-icons/cf-burger classes match the forced outcome from every start stage;
  • footer border-box height stays pinned at its pre-probe value and the footer is hidden for every class mutation/measurement of the ladder;
  • #messages clientHeight changes at most once (start → resolved geometry) and not at all when the pass lands on its starting stage (the SSE steady state); no intermediate stage is ever painted;
  • prior inline height/visibility are restored verbatim, with the freeze/release writes in the expected order;
  • zero-height footer skips the freeze; an exception during measurement still releases the frozen box;
  • control run with the freeze made ineffective reproduces the jitter, so the assertions are not vacuous.

python -m pytest tests/test_composer_footer_fit_freeze.py -q → 9 passed. Against the pre-fix ui.js at e168b67e4, the height, jitter, painted-stage and style-restoration assertions fail (4 failed, 5 passed). test_mobile_layout.py + test_issue1431_toolsets_chip_responsive.py still pass (89 passed); ruff clean.

@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 Sep 3, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — GREEN ✅

Certified head: sha:3d5724a25bb82efd081f1adc1065811b93f5db5e (@ruizanthony)

Integration: clean synthetic rebase 99d693ea4be5bac344f3a79fc37e1dc21919c3e6 on current master 1f2c7abef0474e417dec039f0d44155897970a54; both patch IDs preserved.

Verdict: the synchronous footer freeze preserves transcript viewport geometry while fit-stage classes are probed, restores exact caller styles in finally, and leaves adaptive stage selection unchanged. No regression was reproduced.

Evidence

  • Codex: SAFE TO SHIP. Real Chromium at 390–1800 px preserved footer/message heights and inline styles; compact-stage pinned scrolling remained anchored while base reproduced a 10 px clamp; resize/mutation cycles retained focus and keystrokes. Forty-six focused tests passed.
  • Opus 4.8: SHIP. Verified border-box invariance, exception cleanup, 9/9 submitted tests, 76/76 mobile-layout tests, and strong mutation sensitivity against the actual pre-fix function.
  • Fable 5.1: SHIP-UX. Sandboxed Chromium motion proof found pre-fix one backward 10 px clamp and fixed zero backward moves, stable 635 px viewport / 56 px footer, zero hidden frames, and final bottom anchoring at 1000 px; 1300 px long-label and 390×520/orientation/focus controls also passed. Screenshots are not informative because the intended result is visual invariance; the time-series motion evidence is the correct artifact.
  • Submitted target: 9/9 passed after Fable probe cleanup.
  • Full suite comparison: candidate 15,019 passed / 9 failed / 2 errors; immutable exact-base control 15,010 passed with the identical non-pass set. Maintained receipt valid:true, zero candidate-only/control-only nodes, expected +9 passes.
  • Threat scan: CLEAN; all owned worktrees are clean at the expected SHA.

Recommendation

Ready to merge at 3d5724a25bb82efd081f1adc1065811b93f5db5e. Promote to T1 and preserve @ruizanthony attribution. No remaining screenshot or product decision is pending; the motion/geometry gate is complete.

@nesquena-hermes nesquena-hermes added the gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent label Sep 8, 2026
nesquena-hermes added a commit that referenced this pull request Sep 9, 2026
* fix(composer): stop footer fit from jittering the transcript during SSE

_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.

For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.

Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:

  before: 4 non-JS backward jumps of exactly -8px per streamed turn,
          each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
          class mutation pair
  after:  0 backward jumps over 5 streamed turns on 2 different sessions

Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.

Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.

* test(composer): cover footer fit freeze across full/icons/burger stages

Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.

The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.

* docs(changelog): stamp #7275 composer footer-fit jitter fix

* ci: re-trigger (playwright browser install hung on GH runners)

* ci: re-trigger (google chrome apt mirror hash-sum mismatch, external infra)

---------

Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: n <a@n>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in exp-v0.52.283 (experimental channel) — thanks @ruizanthony! 🎉

Your composer footer-fit freeze is live: the fit-stage probe now runs synchronously with transcript geometry frozen and caller styles restored in finally, so footer sizing no longer nudges the reader's scroll position during SSE streaming. Adaptive stage selection is unchanged.

Gate evidence at exact head 3d5724a2: Codex SAFE TO SHIP · Opus 4.8 SHIP · Fable 5.1 SHIP-UX (time-series motion proof: pre-fix 1 backward 10px clamp → fixed 0 backward moves) · full suite 15,019 pass (base control 15,010, identical non-pass set, zero candidate-only failures) · threat CLEAN. Independently re-verified in the release lane (clean 3-dot diff, node -c, 9/9 own regression tests).

It'll promote to the stable channel after the standard experimental soak.

itsdarklikehell pushed a commit to itsdarklikehell/hermes-webui that referenced this pull request Sep 16, 2026
* fix(composer): stop footer fit from jittering the transcript during SSE

_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.

For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.

Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:

  before: 4 non-JS backward jumps of exactly -8px per streamed turn,
          each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
          class mutation pair
  after:  0 backward jumps over 5 streamed turns on 2 different sessions

Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.

Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.

* test(composer): cover footer fit freeze across full/icons/burger stages

Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.

The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.

* docs(changelog): stamp nesquena#7275 composer footer-fit jitter fix

* ci: re-trigger (playwright browser install hung on GH runners)

* ci: re-trigger (google chrome apt mirror hash-sum mismatch, external infra)

---------

Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: n <a@n>
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
* fix(composer): stop footer fit from jittering the transcript during SSE

_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.

For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.

Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:

  before: 4 non-JS backward jumps of exactly -8px per streamed turn,
          each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
          class mutation pair
  after:  0 backward jumps over 5 streamed turns on 2 different sessions

Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.

Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.

* test(composer): cover footer fit freeze across full/icons/burger stages

Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.

The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.

* docs(changelog): stamp nesquena#7275 composer footer-fit jitter fix

* ci: re-trigger (playwright browser install hung on GH runners)

* ci: re-trigger (google chrome apt mirror hash-sum mismatch, external infra)

---------

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

Labels

gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants