Add a user toggle for a replacement MR overview
First of four stacked MRs that replace the merge request Overview tab with a new overview, gated by the mr_ai_overview feature flag plus a per-user toggle. This MR is the plumbing only: the toggle shows up in the merge request overflow menu, and opting in swaps the classic Overview tab for an empty container. The panels that fill it land in the follow-ups, so this one is safe to review on its own.
Stacked chain, each MR targets the one above it:
- !252481 (merged) (this MR) - feature flag, toggle, empty container
- !252997 - layout and merge readiness
- !252998 - reviewers panel
- !252999 - description, labels and milestone
First MR is just switching functionality:
Detailed context for AI agents
Why this shape
Earlier revisions of this MR added a separate "AI Overview" tab. Review feedback asked for the flag-plus-toggle shape instead: the flag decides who is even offered the toggle, and the toggle is the user's own opt-in, same as Rapid Diffs. The new overview then replaces the Overview tab rather than sitting beside it.
The two gates
ai_overview_available?-::Feature.enabled?(:mr_ai_overview, current_user, type: :wip). Decides whether the toggle appears at all.ai_overview_enabled?- available and themr_ai_overview_enabledcookie is'true'. Decides which overview renders.
Neither takes a project argument, because neither reads one. The actor is deliberately the user rather than the project's root ancestor: a root-ancestor actor meant enabling the flag for one person switched it on for everyone in their group. current_user matches how rapid_diffs_on_mr_show is checked, so the toggle only appears for people it has been turned on for.
Both are CE stubs in app/helpers/merge_requests_helper.rb returning false, overridden in ee/app/helpers/ee/merge_requests_helper.rb, so CE never renders the new overview and the flag lookup only exists in EE.
The toggle itself is a GlDisclosureDropdownItem in mr_more_dropdown.vue that writes the cookie and reloads, mirroring rapid_diffs_page_enabled?. _mr_title.html.haml passes both booleans through to .js-mr-more-dropdown, and both were added to that partial's fragment cache key - without that, the toggle label would be cached across the switch.
The overview's frontend assets live under ee/app/assets/javascripts/merge_requests/ai_overview/, with a stub at app/assets/javascripts/merge_requests/ai_overview/index.js that only exists so the ee_else_ce/merge_requests/ai_overview import resolves in FOSS. It is never called there, because the mount point is never rendered.
What full replacement costs
When the toggle is on, the #notes pane renders the widget data script, the empty overview container, the discussions root div and the sidebar options script. There is no merge widget, no discussion thread, no issuable sidebar and no merge button anywhere on the page. That was a deliberate call, not an oversight.
Two consequences had to be fixed rather than accepted:
window.gl.mrWidgetDatais read by more than the widget. The Pipelines tab, the Reports tab (app/assets/javascripts/merge_requests/reports/merge_request_data.js) and the Web IDE shortcut all read paths off it. With the widget gone the object never existed:merge_request_data.js'sstart()returned early, the store stayednull, and the Reports tab showed a spinner forever with no request in flight and no error. Fix: thewindow.gl.mrWidgetDatabootstrap was split out ofapp/views/projects/merge_requests/_widget.html.hamlinto a newapp/views/projects/merge_requests/_widget_data.html.haml, which both branches of the pane render. Reported by the backend reviewer.- The sidebar bundle owns controls that live outside the sidebar: lock merge request (rendered by
mr_more_dropdown.vuein the title area), to-do, subscribe and submit review. SkippinginitSidebarBundle()left those visible but dead. Fix: the%script.js-sidebar-optionstag and thesidebar_extraspage-startup API call were extracted fromapp/views/shared/issuable/_sidebar.html.hamlintoapp/views/shared/issuable/_sidebar_options.html.haml, which the new overview branch also renders, so the bundle still boots.right_sidebar.jsalso had to null-guard itsdocument.querySelector('.js-right-sidebar')lookup, since that element is genuinely absent under the new overview. Flagged by GitLab Duo on the earlier skip-the-bundle approach.
Why js-vue-mr-discussions survives
initMrNotes calls setupNotesState(document.getElementById('js-vue-mr-discussions').dataset) unconditionally, and the diffs app reads that state for diff commenting. So the element has to stay in the DOM even though the notes app is not mounted. It moved into app/views/projects/merge_requests/_discussions_root.html.haml and both branches of the pane render it.
The notes app is skipped in two places, because there are two mount paths:
app/assets/javascripts/pages/projects/merge_requests/show/index.js- the initial page load. This is the one that matters:MergeRequestTabssetsthis.loadedPages = { [action]: true }in its constructor, sopageBundlesnever fires for the action you landed on.pageBundles.showinmerge_request_tabs.js- client-side tab switches back to Overview.
The suggestions help path is looked up inside _discussions_root.html.haml itself rather than threaded through as a local from _page.html.haml. Minor, non-blocking suggestion from the backend reviewer.
Guarding against a duplicate mount
config/routes/merge_requests.rb maps /-/merge_requests/:iid/commits and /-/merge_requests/:iid/pipelines onto the same show action, so #js-ai-overview is rendered whatever tab you arrive on and the app mounts on page load regardless. MergeRequestTabs only records its own tab in loadedPages, not show, so clicking across to Overview runs pageBundles.show and would mount the overview app a second time.
ee/app/assets/javascripts/merge_requests/ai_overview/index.js mounts detached and appends to el rather than replacing it, deliberately, because the rest of the page reads that element to tell whether the overview is active. It returns early when el.firstElementChild already exists, which is what guards the double mount. Reported by the backend reviewer.
Feature flag
mr_ai_overview, type wip, default_enabled: false, actor is the current user. No changelog entry, since nothing is user-visible with the flag off. Rollout issue: #627189, which records the two known gaps to watch while the toggle is on.
Verification
ee/spec/features/merge_request/user_toggles_ai_overview_spec.rb- 6 examples. Covers: no toggle with the flag off; opting in from the overflow menu and the Overview tab being replaced; the other tabs still working while opted in; the sidebar bundle still booting so the controls it owns outside the sidebar still work; mounting once when arriving on another tab and switching back; opting back out.ee/spec/requests/projects/merge_requests_controller_spec.rb- 6 examples onGET #show. Covers: the toggle offered but the classic overview kept when the flag is on and the cookie is unset; the swap when opted in;js-vue-mr-discussionsstill rendered;window.gl.mrWidgetDatastill defined with nojs-vue-mr-widget; the cookie ignored when the flag is off; the cookie ignored when the flag is enabled for a different user. That last one is what pins the per-user scoping.ee/spec/helpers/ee/merge_requests_helper_spec.rb- 7 examples on the two predicates.- Jest:
mr_more_dropdown_spec.jsasserts the cookie written in both directions,merge_request_tabs_spec.jsasserts which bundlepageBundles.showpicks,ee/spec/frontend/merge_requests/ai_overview/index_spec.jscovers the mount and the duplicate-mount guard,spec/frontend/issuable/index_spec.jscovers the sidebar init surviving a missing right sidebar. - Rubocop, haml-lint, prettier and eslint run clean on every changed file.
Not done
No internal event tracking on the toggle. Turning it into a measurable experiment needs an event definition, which is not in this MR.
