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:

First MR is just switching functionality:

Screenshot_2026-09-01_at_15.03.08

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 the mr_ai_overview_enabled cookie 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:

  1. window.gl.mrWidgetData is 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's start() returned early, the store stayed null, and the Reports tab showed a spinner forever with no request in flight and no error. Fix: the window.gl.mrWidgetData bootstrap was split out of app/views/projects/merge_requests/_widget.html.haml into a new app/views/projects/merge_requests/_widget_data.html.haml, which both branches of the pane render. Reported by the backend reviewer.
  2. The sidebar bundle owns controls that live outside the sidebar: lock merge request (rendered by mr_more_dropdown.vue in the title area), to-do, subscribe and submit review. Skipping initSidebarBundle() left those visible but dead. Fix: the %script.js-sidebar-options tag and the sidebar_extras page-startup API call were extracted from app/views/shared/issuable/_sidebar.html.haml into app/views/shared/issuable/_sidebar_options.html.haml, which the new overview branch also renders, so the bundle still boots. right_sidebar.js also had to null-guard its document.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: MergeRequestTabs sets this.loadedPages = { [action]: true } in its constructor, so pageBundles never fires for the action you landed on.
  • pageBundles.show in merge_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 on GET #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-discussions still rendered; window.gl.mrWidgetData still defined with no js-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.js asserts the cookie written in both directions, merge_request_tabs_spec.js asserts which bundle pageBundles.show picks, ee/spec/frontend/merge_requests/ai_overview/index_spec.js covers the mount and the duplicate-mount guard, spec/frontend/issuable/index_spec.js covers 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.

Edited by Marc Shaw

Merge request reports

Loading
Loading