[AGILE-241] Opening/closing a side panel disrupts backlog refinement#23808
[AGILE-241] Opening/closing a side panel disrupts backlog refinement#23808thykel wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses AGILE-241 by preserving the backlog refinement scroll position when the shared split-view side panel (content-bodyRight Turbo frame) is opened and closed, preventing the backlog list from jumping back to the top during refinement.
Changes:
- Add scroll snapshot/restore logic to the Backlogs Stimulus controller by hooking
content-bodyRightframe lifecycle events. - Add Vitest unit coverage for scroll preservation behavior and teardown behavior on disconnect.
- Add an RSpec feature spec ensuring scroll position is maintained across opening and closing the side panel during backlog refinement.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| modules/backlogs/spec/features/backlogs/split_view_scroll_position_spec.rb | Adds an end-to-end feature spec that asserts backlog scroll position is preserved across side panel open/close. |
| frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts | Implements scroll position snapshot/restore tied to content-bodyRight Turbo frame renders. |
| frontend/src/stimulus/controllers/dynamic/backlogs.controller.spec.ts | Adds unit tests validating scroll restoration, “at top” no-op behavior, and listener teardown on disconnect. |
| private rememberScrollPosition = ():void => { | ||
| this.savedScrollTop = this.scrollContainers.reduce( | ||
| (max, container) => Math.max(max, container.scrollTop), | ||
| 0, | ||
| ); | ||
| }; |
|
@chomper @myabc gave us the following feedback: "The diagnosis looks correct.. the fix looks a bit "sloppy", although probably works. (I'm pretty sure that the underlying issue is likely that we're misconfiguring Turbo elsewhere - question is whether we can remove the hack and not break a million other things)" What's your thoughts? |
|
🤖 Good feedback, and I think @myabc is right on both counts — the diagnosis holds, and the current fix is a compensating hack rather than a root-cause fix. Let me lay out what's actually going on so we can decide deliberately. Why it's a "hack"
The two plausible root causesThere are two distinct mechanisms, and they're not mutually exclusive:
My recommendationI'd not remove the safety net blindly, because the cleanest root fix (#1) lives in that global monkeypatch — touching it is exactly the "break a million other things" risk, since every framed advance-navigation depends on it. That's a real blast radius, not a hypothetical one. Concretely, I'd suggest:
So: agree it's sloppy, agree the root cause likely lives in Turbo config, but I'd de-risk by confirming the mechanism first rather than ripping out the patch speculatively. Happy to prototype the (No code changed — this is discussion.) |
|
@chomper how about the comment from your friend CoPilot, shall we implement it? |
|
🤖 Yes — Copilot's catch is legit, and it's a real correctness bug in the new code rather than a style nit, so I've implemented it. The bug: The fix (in
Note this hardens the interim fix; it doesn't change my earlier take that the cleaner long-term route is confirming the mechanism and possibly stabilizing the scroll container in
|
|
@chomper this PR diff is starting to explode with slop; can we simplify? |
|
🤖 The diff did balloon — and I'm partly responsible for the last round. Let me look at everything that's in the PR now before cutting. Now I understand the layout: within a session, Let me re-read the full controller to confirm it's coherent. Done — trimmed the controller, which was where the bulk of the accumulated complexity lived. The key realization: Removed:
The scroll logic in Note this is still the localized "save/restore" approach, not the root-cause Turbo fix discussed above — it's just a much leaner version of it.
|
| </div> | ||
| </div> | ||
| `); | ||
| const listFrame = ctx.container.querySelector('#backlogs_container')!; |
There was a problem hiding this comment.
| const listFrame = ctx.container.querySelector('#backlogs_container')!; | |
| const listFrame = ctx.container.querySelector<FrameElement>('#backlogs_container')!; |
🤖 AI-generated PR! Please review it for accuracy and then remove this line.
Ticket
https://community.openproject.org/work_packages/AGILE-241
What are you trying to accomplish?
Fixes AGILE-241: opening or closing the work package side panel during backlog refinement reset the Backlog and scrolled it back to the top, so users lost the spot they were working on (typically a story in the middle or bottom of the list) and had to scroll back every time.
The Backlog list lives in the
backlogs_containerTurbo frame, while the detail/side panel renders into the sharedcontent-bodyRightTurbo frame. Both opening (via the story card / "Open details" action) and closing (the panel's close link) navigatecontent-bodyRightwithturbo-action="advance", and that navigation reset the backlog's scroll position.With this change, the scroll position of the backlog is preserved when the side panel is opened and when it is closed, so the refinement spot is maintained.
What approach did you choose and why?
The backlogs Stimulus controller already wraps the whole page (
content_controller "backlogs"on#content-wrapper), so it can see both the list and the panel frame. I hooked the lifecycle of the sharedcontent-bodyRightframe:turbo:before-frame-renderit snapshots the current scroll position.turbo:frame-renderit reapplies that position — synchronously, then again onrequestAnimationFrameand a short timeout, mirroring the existingkeep-scroll-positioncontroller, to win against the promoted Turbo visit's own scroll handling.A subtlety in the layout CSS (
_base.sass) is that the active scroll container changes between#contentand#content-bodydepending on whether the panel is shown. To stay robust to that switch, the controller records the maxscrollTopacross both candidates and reapplies to both on restore (writingscrollTopto the inactive, non-scrollable container is a harmless no-op). Listeners are scoped with anAbortControllerand torn down ondisconnect.Hooking the single shared frame lifecycle keeps the fix centralized in the backlogs controller rather than touching the shared close-button component (
tab_component) orstory.controller, which would have meant changes in two places and risked affecting the othercontent-bodyRightconsumers (boards, team planner, calendar, notifications).I deliberately did not alter the layout CSS to "stabilize" the scroll container. That was a conditional option in the plan, contingent on confirming the container switch is the dominant cause; the JS fix already handles that case, and a speculative layout change would risk regressing every other split-view page.
Screenshots
No visual changes.
Merge checklist
frontend/src/stimulus/controllers/dynamic/backlogs.controller.spec.ts— restores position on frame render, leaves a top-of-list backlog untouched, and stops restoring after disconnect.:js):modules/backlogs/spec/features/backlogs/split_view_scroll_position_spec.rb— scrolls a 30-story sprint to a bottom story and asserts the position holds across both open and close.