Skip to content

perf(dashboard): stop the rail collapse thrashing the transcript - #1074

Merged
bolichen97 merged 1 commit into
mainfrom
fix/rail-collapse-ro-suppression
Aug 1, 2026
Merged

perf(dashboard): stop the rail collapse thrashing the transcript#1074
bolichen97 merged 1 commit into
mainfrom
fix/rail-collapse-ro-suppression

Conversation

@CrysisDeu

Copy link
Copy Markdown
Collaborator

Problem

Collapsing the left nav rail is laggy on the chat route, and the lag scales with the transcript.

The collapse animates grid-template-columns on the shell grid (website/src/App.tsx:1482) for 150ms. That is a layout property, so the content column's width changes on every frame and every mounted transcript row rewraps — each producing a ResizeObserver entry with a new offsetHeight.

Profiled in isolation (7 mounted markdown-weight rows, 8 collapse+expand cycles, headless Chromium): animating the track vs not multiplied the virtualizer's RO fires and its forced offsetHeight reads by 13–18×, while the final cached heights came out identical — every extra measurement is discarded.

Why it matters

The reads are not the damaging part. The interleave is: each genuine height change calls pinAuto(), a scrollTop write, in between those forced reads. Read → write → read, one write per animation frame, ~9 per toggle. That is textbook layout thrash, and it lands on the largest surface in the app.

What was already protected, and is therefore not the problem: HEIGHT_SYNC_DEBOUNCE_MS (120ms) coalesces the height-sync re-render inside the 150ms window, so there was never a per-frame React render storm. An earlier reading of this bug over-claimed that; the discrimination check on the tests is what corrected it. The scroll writes are the real cost.

Fix (symptom → root cause → change)

Symptom: a laggy collapse that gets worse with transcript length. Root cause: ~9 interleaved scrollTop writes per toggle, driven by a ResizeObserver reacting to transitional widths that are all superseded at the final width.

A settle window, published from useRailWidth — the module that already owns "the rail's collapse is a 150ms grid-template transition" as a fact its consumers need (see its existing note on publishing the stepped track value rather than measuring the DOM). setRailWidth is already the single point App notifies on a track change, so the window is armed there and cannot be forgotten by a future edit to how the rail collapses.

While the window is open, the virtualizer's ResizeObserver:

  • keeps its height-cache updates — layout is already dirty so reading is cheap, and this leaves no stale heights;
  • holds back the pinAuto() write, the height sync, and the window recompute.

Exactly one sync — plus one re-pin for a user who was following — runs when the window closes.

The actively-streaming row is exempt. Stalling its growth for the length of the animation re-creates the spacer lurch that streamingIndex's immediate-sync path exists to prevent (#824, #966). Collapsing the rail mid-turn is rare; a visible lurch is not an acceptable trade for it.

The animation is untouched

Worth stating explicitly because it is easy to assume otherwise: framer-motion never animated the rail's width on desktop. motion.nav's desktop props are animate={{ x: 0 }} (a no-op — x is already 0) with transition={undefined} and no layout prop; its job on that element is the mobile drawer slide. The collapse motion is entirely the CSS grid transition, which this PR does not modify.

Recording

Same 150ms animation in both modes; watch the counter. scrollTop writes this toggle climbs once per frame in BEFORE, and stays at the single post-window re-pin in AFTER — while ResizeObserver fires is unchanged, which is the point (the cache stays warm, so nothing goes stale).

HUD: scrollTop writes per toggle, BEFORE vs AFTER

Full-frame stills

BEFORE — mid-collapse, writes accumulating per frame:

BEFORE

AFTER — one write, same RO fire count:

AFTER

What this recording is and is not. It is a harness that reproduces the exact structure — the real grid-template-columns 150ms cubic-bezier(0.2,0,0,1) transition, 7 mounted variable-height rows (what useVirtualChat keeps mounted at overscan: 6 + tail), and a per-row ResizeObserver that reads offsetHeight and writes scrollTop. It is not the dashboard: the isolated dev gateway does not boot on this machine (it dies after the agent-home warning and never binds its port, which also blocked an unrelated profiling task earlier), so I could not record the real app. Treat it as evidence of the mechanism and the delta, not of an end-user frame rate.

Tests

9 new in website/src/test/useVirtualChat.railCollapse.test.tsx.

Four cover the window itself: not settling at rest; arming on a genuine track change; not arming on an unchanged width (arming on a no-op write would hold the window open under any churn and silently disable height syncing); closing after it elapses.

Five cover the virtualizer. The discriminating one asserts scrollTop writes — zero during the animation, exactly one re-pin after. Against the pre-fix code it is nine, one per frame (expected 9 to be +0), verified by checking out the base version of the file with git show. The rest pin that heights are not left stale (the post-window sync reflects the final width), that nothing is suppressed once the window has closed, that the streaming row keeps its immediate path, and that the pending settle timer is cleared on unmount — deliberately, since the timer calls syncHeightsNow / pinAuto / recomputeWindow, all of which touch state and the scroller.

My first version of these tests passed against the unfixed code. That was not a test bug — it is what exposed that the height-sync storm was already debounced, and made me retarget the assertion at the writes.

All 54 existing virtualizer tests still pass, including the spacerLurch and postStreamLurch guards. Gates: tsc -b clean, eslint clean, 6,572 tests across 544 files.

Manual verification

Not done, and I want to be straight about why: the isolated dev gateway will not boot here, so I could not click the real UI. The behavioural claims are all about observer/write counts across a frame sequence, which the tests assert precisely and a human cannot see — the intended user-visible outcome is no change at all to the animation. The one thing a person should still confirm on a real long transcript is that the collapse feels better and that scroll position does not jump; that is the check I could not perform.

Scope, honestly

  • The transition dates to 2026-07-20 (feat: refine dashboard header and panel transitions #94), so this reduces a long-standing cost rather than reverting a recent regression. The reporter recalls the collapse being smooth in an earlier build; I could not identify a commit that changed it, and the other candidates I checked (SmoothResize, Jun 17; the streaming-settle grace, Jul 31) also predate the window. So something else may still be raising per-row reflow cost — this fix helps regardless of what that turns out to be.
  • This removes the JS-side amplification, not the browser's own reflow: the engine still re-lays-out the content column each frame because a layout property is animating. The structural fix is to snap the grid track to its final width and animate only the rail with a compositable property — deliberately not attempted here, since it changes how the rail is drawn.

Collapsing the left nav rail animates `grid-template-columns` on the shell
grid (App.tsx:1482) for 150ms. That is a LAYOUT property, so the content
column's width changes on every frame of the animation and every mounted
transcript row rewraps -- each producing a ResizeObserver entry with a new
offsetHeight.

Measured in isolation (7 mounted markdown-weight rows, 8 collapse+expand
cycles, headless Chromium): animating the track vs not multiplied the
virtualizer's ResizeObserver fires and its forced offsetHeight reads by
13-18x, while the FINAL cached heights came out identical -- every extra
measurement is discarded.

The damaging part is not the reads, though. It is the read/write interleave:
each genuine height change calls pinAuto(), a scrollTop WRITE, in between
those forced reads. One write per animation frame, ~9 per toggle.

Note what was ALREADY protected and is therefore not the problem:
HEIGHT_SYNC_DEBOUNCE_MS (120ms) coalesces the height-sync re-render inside
the 150ms window, so there was no per-frame React render storm. An earlier
reading of this bug over-claimed that; the scroll writes are the real cost.

Fix: a settle window, published from useRailWidth -- the module that already
owns "the rail's collapse is a 150ms grid-template transition" as a fact its
consumers need. `setRailWidth` is already the single point App notifies on a
track change, so the window is armed there and cannot be forgotten by a
future edit to how the rail collapses.

While the window is open the virtualizer's ResizeObserver:
  - KEEPS its height-cache updates (layout is already dirty, so reading is
    cheap, and this leaves no stale heights), and
  - HOLDS BACK the pinAuto() scrollTop write, the height sync, and the
    window recompute.
Exactly one sync -- plus one re-pin for a user who was following -- runs when
the window closes.

The actively-streaming row is deliberately EXEMPT. Stalling ITS growth for
the length of the animation re-creates the spacer lurch that
`streamingIndex`'s immediate-sync path exists to prevent (PR #824, #966).
Collapsing the rail mid-turn is rare; a visible lurch is not an acceptable
trade for it.

The animation is unchanged. Framer-motion never animated the rail's width on
desktop -- `motion.nav`'s desktop props are `animate={{ x: 0 }}` with
`transition={undefined}` and no `layout` prop; the collapse motion is
entirely the CSS grid transition, which this does not touch.

Tests: 9 new in website/src/test/useVirtualChat.railCollapse.test.tsx.

Four cover the window itself: not settling at rest, arming on a genuine
track change, NOT arming on an unchanged width (arming on a no-op write
would hold the window open under any churn and silently disable height
syncing), and closing after it elapses.

Five cover the virtualizer. The discriminating one asserts scrollTop writes:
zero during the animation, exactly one re-pin after -- against the pre-fix
code it is nine, one per frame (verified by checking out the base version of
the file with `git show`). The others pin that heights are NOT left stale
(the post-window sync reflects the final width), that nothing is suppressed
once the window has closed, that the streaming row keeps its immediate path,
and that the pending settle timer is cleared on unmount.

Gates: tsc clean, eslint clean, 6,572 frontend tests pass across 544 files.
No Python touched.

Scope, honestly: the transition dates to 2026-07-20 (#94), so this reduces a
long-standing cost rather than reverting a recent regression. It also removes
the JS-side amplification, not the browser's own reflow -- the engine still
re-lays-out the content column each frame because a layout property is
animating. The structural fix for that is to snap the grid track and animate
only the rail (compositable), which is a larger change deliberately not
attempted here.
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

Advisory UX-level review of bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b — updated in place on each push; does not block merge.

UX-Verdict: PASS

Pure perf change with no new UI surface; the felt result is a smoother rail collapse, and the streaming-row exemption protects the one visible risk.

Watch

  • During the ~190ms settle window a bottom-following user gets no pinAuto() writes, so rewrapping rows can let the transcript bottom drift then snap once on the deferred re-pin — and the PR's recording is a synthetic harness ("It is not the dashboard"), so this is the one pixel-level behavior no evidence in the PR confirms. Frequency low (rail toggles are occasional), impact a brief visual jump, persistence every toggle on long transcripts. Smallest fix: one manual check on the real chat route while pinned to bottom before merge.

[UX-REVIEWED] bcd5b64

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] bcd5b64

False positive or not applicable? A repository writer can comment:
/ai-review override gpt bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Arbiter — ✅ human override accepted

Human judgment by @CrysisDeu overrides the Arbiter finding for bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b; the recorded reason is authoritative for this commit.

temp-screenshots/ is this repository's established, actively-tooled convention for PR review evidence — not a one-way door introduced by this diff — and the proposed removal would strip the UX Review gate of the images it reads.

A new push requires a new judgment. The defer-longterm label remains available for broader accepted-risk deferrals.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound thrash removal, but the PR's own root cause — animating a layout property — stays, and the user-facing lag fix is unverified.

Watch

  • By the PR's own framing ("the engine still re-lays-out the content column each frame... The structural fix is to snap the grid track... deliberately not attempted"), this suppresses JS amplification of a root cause left in place. Combined with "Manual verification: Not done" and the reporter recalling a smooth earlier build, the plausible outcome is: collapse still feels laggy, and a second (structural) PR is needed anyway — at which point this settle window becomes dead weight. Have a human confirm the felt improvement on a real long transcript before treating the report as resolved.
  • The generic virtualizer (useVirtualChat<T>, used beyond the main chat shell) now hard-imports app-shell state (isRailSettling from useRailWidth). Any other animated layout change (companion panel, app embeds) gets no protection, and the generic layer now knows about one specific animation. Injecting a settle predicate via UseVirtualChatOptions keeps ownership right and generalizes for free.

Suggestions

  • Drop temp-screenshots/ (~880KB of PR-evidence binaries) from the tree — attach to the PR instead; committed blobs live in history forever.
  • Deriving the window from transitionend on the shell grid would remove the duplicated 150ms constant and its acknowledged drift risk.

[DESIGN-REVIEWED] bcd5b64

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Opus 5 Review — ✅ no blocking findings

Reviewed bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] bcd5b64

Verdict parsed from the review's SHA-scoped output markers for commit bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b: <one-sentence reason>

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

/ai-review override arbiter bcd5b64: temp-screenshots/ is this repository's established, actively-tooled convention for PR review evidence — not a one-way door introduced by this diff — and the proposed removal would strip the UX Review gate of the images it reads.

Rebutting rather than complying, because each premise of the escalation is checkably wrong and the suggested fix breaks a different gate. Evidence:

1. "this public repo" — the repository is private.
gh repo view kirodotdev/KiroCrew --json visibility,isPrivate,isFork{"isFork": false, "isPrivate": true, "visibility": "PRIVATE"}. There are no forks, and "permanent in the history of a public repo … across every clone and fork" does not describe this repository. Blobs in history are permanent regardless of visibility, so the weight point survives — but the framing that carried it does not.

2. "a one-way door created by this diff" — it is a 58-commit-deep repository standard.

3. The proposed "smallest possible fix" would break the UX Review gate.
.github/workflows/ux-review.yml both triggers on and reads this path: it gates on ^(website/|temp-screenshots/|\.github/screenshots/) (L67) and instructs the reviewer to "Read each one" of the images "under temp-screenshots/ or .github/screenshots/" (L133). Deleting the directory from this PR removes the input that made UX Review pass on this SHA. "Nothing else in the change needs to move" is not accurate — a gate does.

4. The suggested alternative has no working form here.
"Attach to the PR instead" needs a durable host. Branch-pinned raw/ URLs break when the branch is deleted on merge; external hosts are camo-blocked for private repositories; and GitHub comment-attachment uploads (user-attachments/assets/…) have no supported gh/REST path for a non-interactive client. The SHA-pinned committed blob is the only option that renders reliably in a private repo and survives the merge — which is why the convention settled there.

What I accept from the escalation: committed binaries are permanent, and that is a real cost worth keeping small. I kept it small before pushing — the decisive artifact is a 330 KB HUD crop, re-encoded down from a 6.2 MB capture, with the two full-frame stills folded into <details> for context (and read by the UX reviewer). I have not padded the payload.

The four non-escalated items are all fair and I am not contesting them; three are worth doing as follow-ups rather than widening this PR:

  • Invert the dependency — pass a settle predicate through UseVirtualChatOptions instead of useVirtualChat importing isRailSettling, so the companion panel and app embeds get the same protection and the generic virtualizer stops knowing about one animation. This is the best of the three.
  • Derive the window from transitionend instead of the duplicated RAIL_TRANSITION_MS = 150, removing the drift the code comment already admits to.
  • The structural fix (stop animating a layout property), which the PR body already names as deliberately not attempted.

On the UX reviewer's Watch item and the manual check: both are called out as open in the PR body's own "Manual verification" section — the isolated dev gateway does not boot on this machine, so a human still needs to confirm the collapse on a real long transcript pinned to bottom. I did not claim otherwise.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@CrysisDeu marked the arbiter AI finding as false positive, not applicable, or explicitly accepted for bcd5b64cc5b99c17c433e0c33c8b92ce4eeca48b.

temp-screenshots/ is this repository's established, actively-tooled convention for PR review evidence — not a one-way door introduced by this diff — and the proposed removal would strip the UX Review gate of the images it reads.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 1, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: independently re-reviewed the full diff against the GPT + Opus/AUTOSDE contracts (reachable correctness/security, fail-closed/permission boundaries, cross-OS, and deleted-in-use i18n keys per the #976 class). No reachable Critical/High found; all CI checks green.

@bolichen97
bolichen97 enabled auto-merge (squash) August 1, 2026 23:15
@bolichen97
bolichen97 merged commit af728b9 into main Aug 1, 2026
42 checks passed
@bolichen97
bolichen97 deleted the fix/rail-collapse-ro-suppression branch August 1, 2026 23:15
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 1, 2026
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…odotdev#1074)

Collapsing the left nav rail animates `grid-template-columns` on the shell
grid (App.tsx:1482) for 150ms. That is a LAYOUT property, so the content
column's width changes on every frame of the animation and every mounted
transcript row rewraps -- each producing a ResizeObserver entry with a new
offsetHeight.

Measured in isolation (7 mounted markdown-weight rows, 8 collapse+expand
cycles, headless Chromium): animating the track vs not multiplied the
virtualizer's ResizeObserver fires and its forced offsetHeight reads by
13-18x, while the FINAL cached heights came out identical -- every extra
measurement is discarded.

The damaging part is not the reads, though. It is the read/write interleave:
each genuine height change calls pinAuto(), a scrollTop WRITE, in between
those forced reads. One write per animation frame, ~9 per toggle.

Note what was ALREADY protected and is therefore not the problem:
HEIGHT_SYNC_DEBOUNCE_MS (120ms) coalesces the height-sync re-render inside
the 150ms window, so there was no per-frame React render storm. An earlier
reading of this bug over-claimed that; the scroll writes are the real cost.

Fix: a settle window, published from useRailWidth -- the module that already
owns "the rail's collapse is a 150ms grid-template transition" as a fact its
consumers need. `setRailWidth` is already the single point App notifies on a
track change, so the window is armed there and cannot be forgotten by a
future edit to how the rail collapses.

While the window is open the virtualizer's ResizeObserver:
  - KEEPS its height-cache updates (layout is already dirty, so reading is
    cheap, and this leaves no stale heights), and
  - HOLDS BACK the pinAuto() scrollTop write, the height sync, and the
    window recompute.
Exactly one sync -- plus one re-pin for a user who was following -- runs when
the window closes.

The actively-streaming row is deliberately EXEMPT. Stalling ITS growth for
the length of the animation re-creates the spacer lurch that
`streamingIndex`'s immediate-sync path exists to prevent (PR kirodotdev#824, kirodotdev#966).
Collapsing the rail mid-turn is rare; a visible lurch is not an acceptable
trade for it.

The animation is unchanged. Framer-motion never animated the rail's width on
desktop -- `motion.nav`'s desktop props are `animate={{ x: 0 }}` with
`transition={undefined}` and no `layout` prop; the collapse motion is
entirely the CSS grid transition, which this does not touch.

Tests: 9 new in website/src/test/useVirtualChat.railCollapse.test.tsx.

Four cover the window itself: not settling at rest, arming on a genuine
track change, NOT arming on an unchanged width (arming on a no-op write
would hold the window open under any churn and silently disable height
syncing), and closing after it elapses.

Five cover the virtualizer. The discriminating one asserts scrollTop writes:
zero during the animation, exactly one re-pin after -- against the pre-fix
code it is nine, one per frame (verified by checking out the base version of
the file with `git show`). The others pin that heights are NOT left stale
(the post-window sync reflects the final width), that nothing is suppressed
once the window has closed, that the streaming row keeps its immediate path,
and that the pending settle timer is cleared on unmount.

Gates: tsc clean, eslint clean, 6,572 frontend tests pass across 544 files.
No Python touched.

Scope, honestly: the transition dates to 2026-07-20 (kirodotdev#94), so this reduces a
long-standing cost rather than reverting a recent regression. It also removes
the JS-side amplification, not the browser's own reflow -- the engine still
re-lays-out the content column each frame because a layout property is
animating. The structural fix for that is to snap the grid track and animate
only the rail (compositable), which is a larger change deliberately not
attempted here.

Co-authored-by: Zezhen Xu <zezhexu@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants