Merge "Fix target area for expanding/collapsing check result rows"
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index b2dcfb8..4c594892 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -367,6 +367,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 63601d2..45b1e66 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3326,6 +3326,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 9b86a4f..269d1c4 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,6 +30,7 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -110,7 +111,9 @@
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -252,6 +255,7 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+    final ExperimentFeatures experimentFeatures;
 
     @Inject
     Globals(
@@ -269,7 +273,8 @@
         RetryHelper retryHelper,
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
+        ExperimentFeatures experimentFeatures) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -286,6 +291,7 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
+      this.experimentFeatures = experimentFeatures;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -775,6 +781,11 @@
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
+      if (rsrc instanceof RevisionResource
+          && globals.experimentFeatures.isFeatureEnabled(
+              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
+        return null;
+      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -1056,7 +1067,7 @@
 
     if (rsrc instanceof RestResource.HasETag) {
       String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
+      if (!Strings.isNullOrEmpty(have)) {
         String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
         return have.equals(eTag);
       }
@@ -1134,7 +1145,9 @@
       res.setHeader(HttpHeaders.ETAG, eTag);
     } else if (rsrc instanceof RestResource.HasETag) {
       String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-      res.setHeader(HttpHeaders.ETAG, eTag);
+      if (!Strings.isNullOrEmpty(eTag)) {
+        res.setHeader(HttpHeaders.ETAG, eTag);
+      }
     }
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index af49438..0f85578 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,6 +22,9 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
+  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
+      "GerritBackendRequestFeature__remove_revision_etag";
+
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
       ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index 527129c..f3c0fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -34,6 +34,6 @@
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
-    return Response.withMustRevalidate(delegate.format(rsrc));
+    return Response.ok(delegate.format(rsrc));
   }
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index f7d5a59..da7f4e8 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -264,6 +264,19 @@
   buttonType: ContextButtonType;
 }
 
+export declare type ImageDiffAction =
+  | {
+      type: 'overview-image-clicked';
+    }
+  | {
+      type: 'overview-frame-dragged';
+    }
+  | {type: 'magnifier-clicked'}
+  | {type: 'magnifier-dragged'}
+  | {type: 'version-switcher-clicked'; button: 'base' | 'revision'}
+  | {type: 'zoom-level-changed'; scale: number | 'fit'}
+  | {type: 'follow-mouse-changed'; value: boolean};
+
 export enum GrDiffLineType {
   ADD = 'add',
   BOTH = 'both',
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 363f6e5..689347a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -58,6 +58,13 @@
 }
 
 /**
+ * @desc Templates that can be used in change log messages.
+ */
+export enum ChangeMessageTemplate {
+  ACCOUNT_TEMPLATE = '<GERRIT_ACCOUNT_(\\d+)>',
+}
+
+/**
  * @desc Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
  * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 696b9e4..89f7b89 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -19,7 +19,6 @@
 import {GrLitElement} from '../../lit/gr-lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   CheckResult,
   CheckRun,
@@ -264,10 +263,6 @@
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends GrLitElement {
-  private readonly newChangeSummaryUiEnabled = appContext.flagsService.isEnabled(
-    KnownExperimentId.NEW_CHANGE_SUMMARY_UI
-  );
-
   @property({type: Object})
   changeComments?: ChangeComments;
 
@@ -312,9 +307,6 @@
           display: block;
           color: var(--deemphasized-text-color);
           max-width: 650px;
-          /* temporary for old checks status */
-        }
-        :host.new-change-summary-true {
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
@@ -500,7 +492,7 @@
               )}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
             </td>
           </tr>
-          <tr ?hidden=${!this.newChangeSummaryUiEnabled}>
+          <tr>
             <td class="key">Comments</td>
             <td class="value">
               <span
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index b753cf9..c34703e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -38,7 +38,6 @@
 import '../gr-included-in-dialog/gr-included-in-dialog';
 import '../gr-messages-list/gr-messages-list';
 import '../gr-related-changes-list/gr-related-changes-list';
-import '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
@@ -52,10 +51,7 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {
-  getComputedStyleValue,
-  windowLocationReload,
-} from '../../../utils/dom-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -172,7 +168,6 @@
 import {takeUntil} from 'rxjs/operators';
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
-import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
@@ -189,17 +184,6 @@
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
-// These are the same as the breakpoint set in CSS. Make sure both are changed
-// together.
-const BREAKPOINT_RELATED_SMALL = '50em';
-const BREAKPOINT_RELATED_MED = '75em';
-
-// In the event that the related changes medium width calculation is too close
-// to zero, provide some height.
-const MINIMUM_RELATED_MAX_HEIGHT = 100;
-
-const SMALL_RELATED_HEIGHT = 400;
-
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
 const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
@@ -471,9 +455,6 @@
   })
   _commitCollapsible?: boolean;
 
-  @property({type: Boolean})
-  _relatedChangesCollapsed = true;
-
   @property({type: Number})
   _updateCheckTimerHandle?: number | null;
 
@@ -483,9 +464,6 @@
   })
   _editMode?: boolean;
 
-  @property({type: Boolean, observer: '_updateToggleContainerClass'})
-  _showRelatedToggle = false;
-
   @property({
     type: Boolean,
     computed: '_isParentCurrent(_currentRevisionActions)',
@@ -1331,7 +1309,6 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this.getRelatedChangesList()?.clear();
     this._reload(true).then(() => {
       this._performPostLoadTasks();
     });
@@ -1965,6 +1942,9 @@
         this.restApiService.getChange(changeId)
       )
     ).then(changes => {
+      changes = changes.filter(
+        change => change?.status !== ChangeStatus.ABANDONED
+      );
       if (!changes.length) return;
       const change = changes.find(
         change => change?.status === ChangeStatus.MERGED
@@ -2201,7 +2181,6 @@
       return Promise.resolve([]);
     }
     this._loading = true;
-    this._relatedChangesCollapsed = true;
     this.reporting.time(Timing.CHANGE_RELOAD);
     this.reporting.time(Timing.CHANGE_DATA);
 
@@ -2299,30 +2278,25 @@
     if (isLocationChange) {
       this._editingCommitMessage = false;
       const relatedChangesLoaded = coreDataPromise.then(() => {
-        this.getRelatedChangesList()?.reload();
-        if (this._isNewChangeSummaryUiEnabled) {
-          let relatedChangesPromise:
-            | Promise<RelatedChangesInfo | undefined>
-            | undefined;
-          const patchNum = this._computeLatestPatchNum(this._allPatchSets);
-          if (this._change && patchNum) {
-            relatedChangesPromise = this.restApiService
-              .getRelatedChanges(this._change._number, patchNum)
-              .then(response => {
-                if (this._change && response) {
-                  this.hasParent = this._calculateHasParent(
-                    this._change.change_id,
-                    response.changes
-                  );
-                }
-                return response;
-              });
-          }
-          // TODO: use returned Promise
-          this.getRelatedChangesListExperimental()?.reload(
-            relatedChangesPromise
-          );
+        let relatedChangesPromise:
+          | Promise<RelatedChangesInfo | undefined>
+          | undefined;
+        const patchNum = this._computeLatestPatchNum(this._allPatchSets);
+        if (this._change && patchNum) {
+          relatedChangesPromise = this.restApiService
+            .getRelatedChanges(this._change._number, patchNum)
+            .then(response => {
+              if (this._change && response) {
+                this.hasParent = this._calculateHasParent(
+                  this._change.change_id,
+                  response.changes
+                );
+              }
+              return response;
+            });
         }
+        // TODO: use returned Promise
+        this.getRelatedChangesList()?.reload(relatedChangesPromise);
       });
       allDataPromises.push(relatedChangesLoaded);
     }
@@ -2423,15 +2397,6 @@
     return collapsible && collapsed;
   }
 
-  _computeRelatedChangesClass(collapsed: boolean) {
-    return collapsed ? 'collapsed' : '';
-  }
-
-  _computeCollapseText(collapsed: boolean) {
-    // Symbols are up and down triangles.
-    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-  }
-
   /**
    * Returns the text to be copied when
    * click the copy icon next to change subject
@@ -2451,13 +2416,6 @@
     }
   }
 
-  _toggleRelatedChangesCollapsed() {
-    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
-    if (this._relatedChangesCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
   _computeCommitCollapsible(commitMessage?: string) {
     if (!commitMessage) {
       return false;
@@ -2468,124 +2426,6 @@
     return commitMessage.split('\n').length >= MIN_LINES;
   }
 
-  _getOffsetHeight(element: HTMLElement) {
-    return element.offsetHeight;
-  }
-
-  _getScrollHeight(element: HTMLElement) {
-    return element.scrollHeight;
-  }
-
-  /**
-   * Get the line height of an element to the nearest integer.
-   */
-  _getLineHeight(element: Element) {
-    const lineHeightStr = getComputedStyle(element).lineHeight;
-    return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
-  }
-
-  /**
-   * New max height for the related changes section, shorter than the existing
-   * change info height.
-   */
-  _updateRelatedChangeMaxHeight() {
-    // Takes into account approximate height for the expand button and
-    // bottom margin.
-    const EXTRA_HEIGHT = 30;
-    let newHeight;
-
-    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
-      // In a small (mobile) view, give the relation chain some space.
-      newHeight = SMALL_RELATED_HEIGHT;
-    } else if (
-      window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
-    ) {
-      // Since related changes are below the commit message, but still next to
-      // metadata, the height should be the height of the metadata minus the
-      // height of the commit message to reduce jank. However, if that doesn't
-      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
-      // Note: extraHeight is to take into account margin/padding.
-      const medRelatedHeight = Math.max(
-        this._getOffsetHeight(this.$.mainChangeInfo) -
-          this._getOffsetHeight(this.$.commitMessage) -
-          2 * EXTRA_HEIGHT,
-        MINIMUM_RELATED_MAX_HEIGHT
-      );
-      newHeight = medRelatedHeight;
-    } else {
-      if (this._commitCollapsible) {
-        // Make sure the content is lined up if both areas have buttons. If
-        // the commit message is not collapsed, instead use the change info
-        // height.
-        newHeight = this._getOffsetHeight(this.$.commitMessage);
-      } else {
-        newHeight =
-          this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
-      }
-    }
-    const stylesToUpdate: {[key: string]: string} = {};
-
-    const relatedChanges = this.getRelatedChangesList();
-    // Get the line height of related changes, and convert it to the nearest
-    // integer.
-    const DEFAULT_LINE_HEIGHT = 20;
-    const lineHeight = relatedChanges
-      ? this._getLineHeight(relatedChanges)
-      : DEFAULT_LINE_HEIGHT;
-
-    // Figure out a new height that is divisible by the rounded line height.
-    const remainder = newHeight % lineHeight;
-    newHeight = newHeight - remainder;
-
-    stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
-
-    // Update the max-height of the relation chain to this new height.
-    if (this._commitCollapsible) {
-      stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
-    }
-
-    this.updateStyles(stylesToUpdate);
-  }
-
-  _computeShowRelatedToggle() {
-    // Make sure the max height has been applied, since there is now content
-    // to populate.
-    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
-      this._updateRelatedChangeMaxHeight();
-    }
-    // Prevents showMore from showing when click on related change, since the
-    // line height would be positive, but related changes height is 0.
-    const relatedChanges = this.getRelatedChangesList();
-    if (relatedChanges) {
-      if (!this._getScrollHeight(relatedChanges)) {
-        return (this._showRelatedToggle = false);
-      }
-
-      if (
-        this._getScrollHeight(relatedChanges) >
-        this._getOffsetHeight(relatedChanges) +
-          this._getLineHeight(relatedChanges)
-      ) {
-        return (this._showRelatedToggle = true);
-      }
-    }
-    return (this._showRelatedToggle = false);
-  }
-
-  _updateToggleContainerClass(showRelatedToggle: boolean) {
-    const relatedChangesToggle = this.shadowRoot!.querySelector<HTMLDivElement>(
-      '#relatedChangesToggle'
-    );
-    if (!relatedChangesToggle) {
-      return;
-    }
-    if (showRelatedToggle) {
-      relatedChangesToggle.classList.add('showToggle');
-    } else {
-      relatedChangesToggle.classList.remove('showToggle');
-    }
-  }
-
   _startUpdateCheckTimer() {
     if (
       !this._serverConfig ||
@@ -2872,12 +2712,6 @@
       '#relatedChanges'
     );
   }
-
-  getRelatedChangesListExperimental() {
-    return this.shadowRoot!.querySelector<GrRelatedChangesListExperimental>(
-      '#relatedChangesExperimental'
-    );
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index de08318..211810c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -165,12 +165,6 @@
       height: 0;
       margin-bottom: var(--spacing-l);
     }
-    #relatedChanges.collapsed {
-      margin-bottom: var(--spacing-l);
-      max-height: var(--relation-chain-max-height, 2em);
-      overflow: hidden;
-      position: relative; /* for arrowToCurrentChange to have position:absolute and be hidden */
-    }
     .emptySpace {
       flex-grow: 1;
     }
@@ -185,19 +179,9 @@
       display: flex;
       margin-bottom: 8px;
     }
-    #relatedChangesToggle {
-      display: none;
-    }
-    #relatedChangesToggle.showToggle {
-      display: flex;
-    }
     .collapseToggleContainer gr-button {
       display: block;
     }
-    #relatedChangesToggle {
-      margin-left: var(--spacing-l);
-      padding-top: var(--related-change-btn-top-padding, 0);
-    }
     .showOnEdit {
       display: none;
     }
@@ -243,8 +227,6 @@
       /* temporary for old checks status */
       margin-bottom: var(--spacing-m);
     }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_MED in the JS */
     @media screen and (max-width: 75em) {
       .relatedChanges {
         padding: 0;
@@ -266,8 +248,6 @@
         padding-right: 0;
       }
     }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_SMALL in the JS */
     @media screen and (max-width: 50em) {
       .mobile {
         display: block;
@@ -520,7 +500,6 @@
                 </div>
               </template>
               <gr-change-summary
-                class$="new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
                 self-account="[[_account]]"
@@ -537,36 +516,11 @@
               </gr-endpoint-decorator>
             </div>
             <div class="relatedChanges">
-              <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
-                <gr-related-changes-list-experimental
-                  change="[[_change]]"
-                  id="relatedChangesExperimental"
-                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-                ></gr-related-changes-list-experimental>
-              </template>
-              <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
-                <gr-related-changes-list
-                  id="relatedChanges"
-                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
-                  change="[[_change]]"
-                  mergeable="[[_mergeable]]"
-                  has-parent="{{hasParent}}"
-                  on-update="_updateRelatedChangeMaxHeight"
-                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-                  on-new-section-loaded="_computeShowRelatedToggle"
-                >
-                </gr-related-changes-list>
-                <div id="relatedChangesToggle" class="collapseToggleContainer">
-                  <gr-button
-                    link=""
-                    id="relatedChangesToggleButton"
-                    class="collapseToggleButton"
-                    on-click="_toggleRelatedChangesCollapsed"
-                  >
-                    [[_computeCollapseText(_relatedChangesCollapsed)]]
-                  </gr-button>
-                </div>
-              </template>
+              <gr-related-changes-list
+                change="[[_change]]"
+                id="relatedChanges"
+                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+              ></gr-related-changes-list>
             </div>
             <div class="emptySpace"></div>
           </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index fc0d289..3807ca9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -30,7 +30,6 @@
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getComputedStyleValue} from '../../../utils/dom-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
@@ -39,7 +38,6 @@
 import 'lodash/lodash';
 import {
   stubRestApi,
-  SinonSpyMember,
   TestKeyboardShortcutBinder,
 } from '../../../test/test-utils';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -47,7 +45,6 @@
   createAppElementChangeViewParams,
   createApproval,
   createChange,
-  createChangeConfig,
   createChangeMessages,
   createCommit,
   createMergeable,
@@ -409,9 +406,6 @@
     });
   });
 
-  const getCustomCssValue = (cssParam: string) =>
-    getComputedStyleValue(cssParam, element);
-
   test('_handleMessageAnchorTap', () => {
     element._changeNum = 1 as NumericChangeId;
     element._patchRange = {
@@ -1308,6 +1302,46 @@
       });
     });
 
+    test('do not show any chip if all reverts are abandoned', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
     test('show revert created if no revert is merged', done => {
       const change = {
         ...createChange(),
@@ -1315,6 +1349,10 @@
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
       change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
       const getChangeStub = stubRestApi('getChange');
       getChangeStub.onFirstCall().returns(
         Promise.resolve({
@@ -1342,7 +1380,7 @@
       });
     });
 
-    test('show revert created if no revert is merged', done => {
+    test('show revert submitted if revert is merged', done => {
       const change = {
         ...createChange(),
         messages: createChangeMessages(2),
@@ -1622,10 +1660,6 @@
       .stub(element, '_reloadPatchNumDependentResources')
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
     flush();
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    const relatedClearSpy = sinon.spy(relatedChanges, 'clear');
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
@@ -1635,7 +1669,6 @@
     };
     element._paramsChanged(value);
     assert.isTrue(reloadStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
 
     element._initialLoadComplete = true;
 
@@ -1644,7 +1677,6 @@
     element._paramsChanged(value);
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
@@ -1657,10 +1689,6 @@
       element.$.commentAPI,
       'reloadPortedComments'
     );
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    sinon.spy(relatedChanges, 'clear');
     sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
@@ -2286,322 +2314,6 @@
     });
   });
 
-  suite('related changes expand/collapse', () => {
-    let updateHeightSpy: SinonSpyMember<
-      typeof element._updateRelatedChangeMaxHeight
-    >;
-    setup(() => {
-      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
-    });
-
-    test('relatedChangesToggle shown height greater than changeInfo height', () => {
-      const relatedChangesToggle = element.shadowRoot!.querySelector(
-        '#relatedChangesToggle'
-      );
-      assert.isFalse(relatedChangesToggle!.classList.contains('showToggle'));
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: true} as MediaQueryList));
-      const relatedChanges = element.shadowRoot!.querySelector(
-        '#relatedChanges'
-      ) as GrRelatedChangesList;
-      relatedChanges.dispatchEvent(new CustomEvent('new-section-loaded'));
-      assert.isTrue(relatedChangesToggle!.classList.contains('showToggle'));
-      assert.equal(updateHeightSpy.callCount, 1);
-    });
-
-    test('relatedChangesToggle hidden height less than changeInfo height', () => {
-      const relatedChangesToggle = element.shadowRoot!.querySelector(
-        '#relatedChangesToggle'
-      );
-      assert.isFalse(relatedChangesToggle!.classList.contains('showToggle'));
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: true} as MediaQueryList));
-      const relatedChanges = element.shadowRoot!.querySelector(
-        '#relatedChanges'
-      ) as GrRelatedChangesList;
-      relatedChanges.dispatchEvent(new CustomEvent('new-section-loaded'));
-      assert.isFalse(relatedChangesToggle!.classList.contains('showToggle'));
-      assert.equal(updateHeightSpy.callCount, 1);
-    });
-
-    test('relatedChangesToggle functions', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: false} as MediaQueryList));
-      assert.isTrue(element._relatedChangesCollapsed);
-      const relatedChangesToggleButton = element.shadowRoot!.querySelector(
-        '#relatedChangesToggleButton'
-      );
-      const relatedChanges = element.shadowRoot!.querySelector(
-        '#relatedChanges'
-      ) as GrRelatedChangesList;
-      assert.isTrue(relatedChanges.classList.contains('collapsed'));
-      tap(relatedChangesToggleButton!);
-      assert.isFalse(element._relatedChangesCollapsed);
-      assert.isFalse(relatedChanges.classList.contains('collapsed'));
-    });
-
-    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: false} as MediaQueryList));
-
-      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '12px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'), '');
-    });
-
-    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: false} as MediaQueryList));
-
-      // 50 (existing height) % 12 (line height) = 2 (remainder).
-      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '48px');
-      assert.equal(
-        getCustomCssValue('--related-change-btn-top-padding'),
-        '2px'
-      );
-    });
-
-    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon
-        .stub(window, 'matchMedia')
-        .callsFake(() => ({matches: true} as MediaQueryList));
-
-      element._updateRelatedChangeMaxHeight();
-
-      // 400 (new height) % 12 (line height) = 4 (remainder).
-      // 400 (new height) - 4 (remainder) = 396.
-
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '396px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
-        if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
-          return {matches: true} as MediaQueryList;
-        } else {
-          return {matches: false} as MediaQueryList;
-        }
-      });
-
-      // 100 (new height) % 12 (line height) = 4 (remainder).
-      // 100 (new height) - 4 (remainder) = 96.
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '96px');
-    });
-
-    suite('update checks', () => {
-      let clock: SinonFakeTimers;
-      let startUpdateCheckTimerSpy: SinonSpyMember<
-        typeof element._startUpdateCheckTimer
-      >;
-      setup(() => {
-        clock = sinon.useFakeTimers();
-        startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
-        element._change = {
-          ...createChangeViewChange(),
-          revisions: createRevisions(1),
-          messages: createChangeMessages(1),
-        };
-      });
-
-      test('_startUpdateCheckTimer negative delay', () => {
-        const getChangeDetailStub = stubRestApi('getChangeDetail').returns(
-          Promise.resolve({
-            ...createChangeViewChange(),
-            // element has latest info
-            revisions: {rev1: createRevision()},
-            messages: createChangeMessages(1),
-            current_revision: 'rev1' as CommitId,
-          })
-        );
-
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: -1},
-        };
-
-        assert.isTrue(startUpdateCheckTimerSpy.called);
-        assert.isFalse(getChangeDetailStub.called);
-      });
-
-      test('_startUpdateCheckTimer up-to-date', async () => {
-        const getChangeDetailStub = stubRestApi('getChangeDetail').callsFake(
-          () =>
-            Promise.resolve({
-              ...createChangeViewChange(),
-              // element has latest info
-              revisions: {rev1: createRevision()},
-              messages: createChangeMessages(1),
-              current_revision: 'rev1' as CommitId,
-            })
-        );
-
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        clock.tick(12345 * 1000);
-        await flush();
-
-        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
-        assert.isTrue(getChangeDetailStub.called);
-      });
-
-      test('_startUpdateCheckTimer out-of-date shows an alert', async () => {
-        stubRestApi('getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChange(),
-            // new patchset was uploaded
-            revisions: createRevisions(2),
-            current_revision: getCurrentRevision(2),
-            messages: createChangeMessages(1),
-          })
-        );
-
-        let alertMessage = 'alert not fired';
-        element.addEventListener('show-alert', e => {
-          alertMessage = e.detail.message;
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        clock.tick(12345 * 1000);
-        await flush();
-
-        assert.equal(alertMessage, 'A newer patch set has been uploaded');
-        assert.equal(startUpdateCheckTimerSpy.callCount, 1);
-      });
-
-      test('_startUpdateCheckTimer respects _loading', async () => {
-        stubRestApi('getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChangeViewChange(),
-            // new patchset was uploaded
-            revisions: createRevisions(2),
-            current_revision: getCurrentRevision(2),
-            messages: createChangeMessages(1),
-          })
-        );
-
-        element._loading = true;
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        clock.tick(12345 * 1000 * 2);
-        await flush();
-
-        // No toast, instead a second call to _startUpdateCheckTimer().
-        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
-      });
-
-      test('_startUpdateCheckTimer new status shows an alert', async () => {
-        stubRestApi('getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChangeViewChange(),
-            // element has latest info
-            revisions: {rev1: createRevision()},
-            messages: createChangeMessages(1),
-            current_revision: 'rev1' as CommitId,
-            status: ChangeStatus.MERGED,
-          })
-        );
-
-        let alertMessage = 'alert not fired';
-        element.addEventListener('show-alert', e => {
-          alertMessage = e.detail.message;
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        clock.tick(12345 * 1000);
-        await flush();
-
-        assert.equal(alertMessage, 'This change has been merged');
-      });
-
-      test('_startUpdateCheckTimer new messages shows an alert', async () => {
-        stubRestApi('getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChangeViewChange(),
-            revisions: {rev1: createRevision()},
-            // element has new message
-            messages: createChangeMessages(2),
-            current_revision: 'rev1' as CommitId,
-          })
-        );
-
-        let alertMessage = 'alert not fired';
-        element.addEventListener('show-alert', e => {
-          alertMessage = e.detail.message;
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        clock.tick(12345 * 1000);
-        await flush();
-
-        assert.equal(alertMessage, 'There are new messages on this change');
-      });
-    });
-
-    test('canStartReview computation', () => {
-      const change1: ChangeInfo = createChange();
-      const change2: ChangeInfo = {
-        ...createChangeViewChange(),
-        actions: {
-          ready: {
-            enabled: true,
-          },
-        },
-      };
-      const change3: ChangeInfo = {
-        ...createChangeViewChange(),
-        actions: {
-          ready: {
-            label: 'Ready for Review',
-          },
-        },
-      };
-      assert.isFalse(element._computeCanStartReview(change1));
-      assert.isTrue(element._computeCanStartReview(change2));
-      assert.isFalse(element._computeCanStartReview(change3));
-    });
-  });
-
   test('header class computation', () => {
     assert.equal(element._computeHeaderClass(), 'header');
     assert.equal(element._computeHeaderClass(true), 'header editMode');
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 3ea9f68..fed02a7 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -25,7 +25,11 @@
 import '../../../styles/gr-voting-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {MessageTag, SpecialFilePath} from '../../../constants/constants';
+import {
+  ChangeMessageTemplate,
+  MessageTag,
+  SpecialFilePath,
+} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -40,6 +44,7 @@
   PatchSetNum,
   AccountInfo,
   BasePatchSetNum,
+  AccountId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -176,14 +181,19 @@
 
   @property({
     type: String,
-    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+    computed:
+      '_computeMessageContentExpanded(message.message,' +
+      ' message.accountsInMessage,' +
+      ' message.tag)',
   })
   _messageContentExpanded = '';
 
   @property({
     type: String,
     computed:
-      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      '_computeMessageContentCollapsed(message.message,' +
+      ' message.accountsInMessage,' +
+      ' message.tag,' +
       ' message.commentThreads)',
   })
   _messageContentCollapsed = '';
@@ -231,8 +241,12 @@
     return pluralize(threadsLength, 'comment');
   }
 
-  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(true, content, tag);
+  _computeMessageContentExpanded(
+    content?: string,
+    accountsInMessage?: AccountInfo[],
+    tag?: ReviewInputTag
+  ) {
+    return this._computeMessageContent(true, content, accountsInMessage, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -261,10 +275,16 @@
 
   _computeMessageContentCollapsed(
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(false, content, tag);
+    const summary = this._computeMessageContent(
+      false,
+      content,
+      accountsInMessage,
+      tag
+    );
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
@@ -319,11 +339,22 @@
   _computeMessageContent(
     isExpanded: boolean,
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
     const isNewPatchSet = this._isNewPatchsetTag(tag);
 
+    if (accountsInMessage) {
+      content = content.replace(
+        new RegExp(ChangeMessageTemplate.ACCOUNT_TEMPLATE, 'g'),
+        (_accountIdTemplate, accountId) =>
+          accountsInMessage.find(
+            account => account._account_id === (Number(accountId) as AccountId)
+          )?.name || `Gerrit Account ${accountId}`
+      );
+    }
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index b8f3c73..97568dc 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -19,6 +19,7 @@
 import './gr-message';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
+  createAccountWithIdNameAndEmail,
   createChange,
   createChangeMessage,
   createComment,
@@ -349,11 +350,21 @@
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(true, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            true,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
         assert.equal(
-          element._computeMessageContent(false, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            false,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
       });
@@ -361,13 +372,13 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, original);
       });
 
@@ -375,13 +386,13 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -389,13 +400,13 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -403,9 +414,9 @@
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -413,9 +424,47 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
+        assert.equal(actual, expected);
+      });
+
+      test('message template', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by User-1\n * Code-Style-1 by User-2';
+        const accountsInMessage = [
+          createAccountWithIdNameAndEmail(1),
+          createAccountWithIdNameAndEmail(2),
+        ];
+        let actual = element._computeMessageContent(
+          true,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(
+          false,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+      });
+
+      test('message template missing accounts', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by Gerrit Account 0000001\n * Code-Style-1 by Gerrit Account 0000002';
+        let actual = element._computeMessageContent(true, original, [], tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
     });
@@ -570,10 +619,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'testing the load'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
 
     test('single patchset comment with reply', () => {
@@ -610,10 +667,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'n'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
deleted file mode 100644
index 8c83b58..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
+++ /dev/null
@@ -1,754 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html, nothing} from 'lit-html';
-import './gr-related-change';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {classMap} from 'lit-html/directives/class-map';
-import {GrLitElement} from '../../lit/gr-lit-element';
-import {
-  customElement,
-  property,
-  css,
-  internalProperty,
-  TemplateResult,
-} from 'lit-element';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {
-  SubmittedTogetherInfo,
-  ChangeInfo,
-  RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
-  PatchSetNum,
-  CommitId,
-} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {ParsedChangeInfo} from '../../../types/types';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {pluralize} from '../../../utils/string-util';
-import {
-  changeIsOpen,
-  getRevisionKey,
-  isChangeInfo,
-} from '../../../utils/change-util';
-
-/** What is the maximum number of shown changes in collapsed list? */
-const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
-
-export interface ChangeMarkersInList {
-  showCurrentChangeArrow: boolean;
-  showWhenCollapsed: boolean;
-  showTopArrow: boolean;
-  showBottomArrow: boolean;
-}
-
-export enum Section {
-  RELATED_CHANGES = 'related changes',
-  SUBMITTED_TOGETHER = 'submitted together',
-  SAME_TOPIC = 'same topic',
-  MERGE_CONFLICTS = 'merge conflicts',
-  CHERRY_PICKS = 'cherry picks',
-}
-
-@customElement('gr-related-changes-list-experimental')
-export class GrRelatedChangesListExperimental extends GrLitElement {
-  @property()
-  change?: ParsedChangeInfo;
-
-  @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property()
-  mergeable?: boolean;
-
-  @internalProperty()
-  submittedTogether?: SubmittedTogetherInfo = {
-    changes: [],
-    non_visible_changes: 0,
-  };
-
-  @internalProperty()
-  relatedChanges: RelatedChangeAndCommitInfo[] = [];
-
-  @internalProperty()
-  conflictingChanges: ChangeInfo[] = [];
-
-  @internalProperty()
-  cherryPickChanges: ChangeInfo[] = [];
-
-  @internalProperty()
-  sameTopicChanges: ChangeInfo[] = [];
-
-  private readonly restApiService = appContext.restApiService;
-
-  static get styles() {
-    return [
-      sharedStyles,
-      css`
-        .note {
-          color: var(--error-text-color);
-          margin-left: 1.2em;
-        }
-        section {
-          margin-bottom: var(--spacing-l);
-        }
-        gr-related-change {
-          display: flex;
-        }
-        .marker {
-          position: absolute;
-          margin-left: calc(-1 * var(--spacing-s));
-        }
-        .arrowToCurrentChange {
-          position: absolute;
-        }
-      `,
-    ];
-  }
-
-  render() {
-    const sectionSize = this.sectionSizeFactory(
-      this.relatedChanges.length,
-      this.submittedTogether?.changes.length || 0,
-      this.sameTopicChanges.length,
-      this.conflictingChanges.length,
-      this.cherryPickChanges.length
-    );
-    const relatedChangesMarkersPredicate = this.markersPredicateFactory(
-      this.relatedChanges.length,
-      this.relatedChanges.findIndex(relatedChange =>
-        this._changesEqual(relatedChange, this.change)
-      ),
-      sectionSize(Section.RELATED_CHANGES)
-    );
-    const connectedRevisions = this._computeConnectedRevisions(
-      this.change,
-      this.patchNum,
-      this.relatedChanges
-    );
-    let firstNonEmptySectionFound = false;
-    let isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.relatedChanges.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const relatedChangeSection = html` <section
-      id="relatedChanges"
-      ?hidden=${!this.relatedChanges.length}
-    >
-      <gr-related-collapse
-        title="Relation chain"
-        class="${classMap({first: isFirstNonEmpty})}"
-        .length=${this.relatedChanges.length}
-        .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
-      >
-        ${this.relatedChanges.map(
-          (change, index) =>
-            html`${this.renderMarkers(
-                relatedChangesMarkersPredicate(index)
-              )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
-                .change="${change}"
-                .connectedRevisions="${connectedRevisions}"
-                .href="${change?._change_number
-                  ? GerritNav.getUrlForChangeById(
-                      change._change_number,
-                      change.project,
-                      change._revision_number as PatchSetNum
-                    )
-                  : ''}"
-                .showChangeStatus=${true}
-                >${change.commit.subject}</gr-related-change
-              >`
-        )}
-      </gr-related-collapse>
-    </section>`;
-
-    const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
-    const countNonVisibleChanges =
-      this.submittedTogether?.non_visible_changes ?? 0;
-    const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
-      submittedTogetherChanges.length,
-      submittedTogetherChanges.findIndex(relatedChange =>
-        this._changesEqual(relatedChange, this.change)
-      ),
-      sectionSize(Section.SUBMITTED_TOGETHER)
-    );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound &&
-      (!!submittedTogetherChanges?.length ||
-        !!this.submittedTogether?.non_visible_changes);
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const submittedTogetherSection = html`<section
-      id="submittedTogether"
-      ?hidden=${!submittedTogetherChanges?.length &&
-      !this.submittedTogether?.non_visible_changes}
-    >
-      <gr-related-collapse
-        title="Submitted together"
-        class="${classMap({first: isFirstNonEmpty})}"
-        .length=${submittedTogetherChanges.length}
-        .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
-      >
-        ${submittedTogetherChanges.map(
-          (change, index) =>
-            html`${this.renderMarkers(
-                submittedTogetherMarkersPredicate(index)
-              )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
-                    index
-                  ).showWhenCollapsed,
-                })}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                .showSubmittableCheck=${true}
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
-              >`
-        )}
-      </gr-related-collapse>
-      <div class="note" ?hidden=${!countNonVisibleChanges}>
-        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
-      </div>
-    </section>`;
-
-    const sameTopicMarkersPredicate = this.markersPredicateFactory(
-      this.sameTopicChanges.length,
-      -1,
-      sectionSize(Section.SAME_TOPIC)
-    );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const sameTopicSection = html`<section
-      id="sameTopic"
-      ?hidden=${!this.sameTopicChanges?.length}
-    >
-      <gr-related-collapse
-        title="Same topic"
-        class="${classMap({first: isFirstNonEmpty})}"
-        .length=${this.sameTopicChanges.length}
-        .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
-      >
-        ${this.sameTopicChanges.map(
-          (change, index) =>
-            html`${this.renderMarkers(
-                sameTopicMarkersPredicate(index)
-              )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
-              >`
-        )}
-      </gr-related-collapse>
-    </section>`;
-
-    const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
-      this.conflictingChanges.length,
-      -1,
-      sectionSize(Section.MERGE_CONFLICTS)
-    );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const mergeConflictsSection = html`<section
-      id="mergeConflicts"
-      ?hidden=${!this.conflictingChanges?.length}
-    >
-      <gr-related-collapse
-        title="Merge conflicts"
-        class="${classMap({first: isFirstNonEmpty})}"
-        .length=${this.conflictingChanges.length}
-        .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
-      >
-        ${this.conflictingChanges.map(
-          (change, index) =>
-            html`${this.renderMarkers(
-                mergeConflictsMarkersPredicate(index)
-              )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                >${change.subject}</gr-related-change
-              >`
-        )}
-      </gr-related-collapse>
-    </section>`;
-
-    const cherryPicksMarkersPredicate = this.markersPredicateFactory(
-      this.cherryPickChanges.length,
-      -1,
-      sectionSize(Section.CHERRY_PICKS)
-    );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const cherryPicksSection = html`<section
-      id="cherryPicks"
-      ?hidden=${!this.cherryPickChanges?.length}
-    >
-      <gr-related-collapse
-        title="Cherry picks"
-        class="${classMap({first: isFirstNonEmpty})}"
-        .length=${this.cherryPickChanges.length}
-        .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
-      >
-        ${this.cherryPickChanges.map(
-          (change, index) =>
-            html`${this.renderMarkers(
-                cherryPicksMarkersPredicate(index)
-              )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                >${change.branch}: ${change.subject}</gr-related-change
-              >`
-        )}
-      </gr-related-collapse>
-    </section>`;
-
-    return html`<gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param
-        name="change"
-        .value=${this.change}
-      ></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
-      ${mergeConflictsSection} ${cherryPicksSection}
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>`;
-  }
-
-  sectionSizeFactory(
-    relatedChangesLen: number,
-    submittedTogetherLen: number,
-    sameTopicLen: number,
-    mergeConflictsLen: number,
-    cherryPicksLen: number
-  ) {
-    const calcDefaultSize = (length: number) =>
-      Math.min(length, DEFALT_NUM_CHANGES_WHEN_COLLAPSED);
-
-    const sectionSizes = [
-      {
-        section: Section.RELATED_CHANGES,
-        size: calcDefaultSize(relatedChangesLen),
-        len: relatedChangesLen,
-      },
-      {
-        section: Section.SUBMITTED_TOGETHER,
-        size: calcDefaultSize(submittedTogetherLen),
-        len: submittedTogetherLen,
-      },
-      {
-        section: Section.SAME_TOPIC,
-        size: calcDefaultSize(sameTopicLen),
-        len: sameTopicLen,
-      },
-      {
-        section: Section.MERGE_CONFLICTS,
-        size: calcDefaultSize(mergeConflictsLen),
-        len: mergeConflictsLen,
-      },
-      {
-        section: Section.CHERRY_PICKS,
-        size: calcDefaultSize(cherryPicksLen),
-        len: cherryPicksLen,
-      },
-    ];
-
-    const FILLER = 1; // space for header
-    let totalSize = sectionSizes.reduce(
-      (acc, val) => acc + val.size + (val.size !== 0 ? FILLER : 0),
-      0
-    );
-
-    const MAX_SIZE = 16;
-    for (let i = 0; i < sectionSizes.length; i++) {
-      if (totalSize >= MAX_SIZE) break;
-      const sizeObj = sectionSizes[i];
-      if (sizeObj.size === sizeObj.len) continue;
-      const newSize = Math.min(
-        MAX_SIZE - totalSize + sizeObj.size,
-        sizeObj.len
-      );
-      totalSize += newSize - sizeObj.size;
-      sizeObj.size = newSize;
-    }
-
-    return (section: Section) => {
-      const sizeObj = sectionSizes.find(sizeObj => sizeObj.section === section);
-      if (sizeObj) return sizeObj.size;
-      return DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
-    };
-  }
-
-  markersPredicateFactory(
-    length: number,
-    highlightIndex: number,
-    numChangesShownWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED
-  ): (index: number) => ChangeMarkersInList {
-    const showWhenCollapsedPredicate = (index: number) => {
-      if (highlightIndex === -1) return index < numChangesShownWhenCollapsed;
-      if (highlightIndex === 0)
-        return index <= numChangesShownWhenCollapsed - 1;
-      if (highlightIndex === length - 1)
-        return index >= length - numChangesShownWhenCollapsed;
-      let numBeforeHighlight = Math.floor(numChangesShownWhenCollapsed / 2);
-      let numAfterHighlight =
-        Math.floor(numChangesShownWhenCollapsed / 2) -
-        (numChangesShownWhenCollapsed % 2 ? 0 : 1);
-      numBeforeHighlight += Math.max(
-        highlightIndex + numAfterHighlight - length + 1,
-        0
-      );
-      numAfterHighlight -= Math.min(0, highlightIndex - numBeforeHighlight);
-      return (
-        highlightIndex - numBeforeHighlight <= index &&
-        index <= highlightIndex + numAfterHighlight
-      );
-    };
-    return (index: number) => {
-      return {
-        showCurrentChangeArrow:
-          highlightIndex !== -1 && index === highlightIndex,
-        showWhenCollapsed: showWhenCollapsedPredicate(index),
-        showTopArrow:
-          index >= 1 &&
-          index !== highlightIndex &&
-          showWhenCollapsedPredicate(index) &&
-          !showWhenCollapsedPredicate(index - 1),
-        showBottomArrow:
-          index <= length - 2 &&
-          index !== highlightIndex &&
-          showWhenCollapsedPredicate(index) &&
-          !showWhenCollapsedPredicate(index + 1),
-      };
-    };
-  }
-
-  renderMarkers(changeMarkers: ChangeMarkersInList) {
-    if (changeMarkers.showCurrentChangeArrow) {
-      return html`<span
-        role="img"
-        class="arrowToCurrentChange"
-        aria-label="Arrow marking current change"
-        >âž”</span
-      >`;
-    }
-    if (changeMarkers.showTopArrow) {
-      return html`<span
-        role="img"
-        class="marker"
-        aria-label="Arrow marking change has collapsed ancestors"
-        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
-      ></span> `;
-    }
-    if (changeMarkers.showBottomArrow) {
-      return html`<span
-        role="img"
-        class="marker"
-        aria-label="Arrow marking change has collapsed descendants"
-        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
-      ></span> `;
-    }
-    return nothing;
-  }
-
-  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
-    const change = this.change;
-    if (!change) return Promise.reject(new Error('change missing'));
-    if (!this.patchNum) return Promise.reject(new Error('patchNum missing'));
-    if (!getRelatedChanges) {
-      getRelatedChanges = this.restApiService.getRelatedChanges(
-        change._number,
-        this.patchNum
-      );
-    }
-    const promises: Array<Promise<void>> = [
-      getRelatedChanges.then(response => {
-        if (!response) {
-          throw new Error('getRelatedChanges returned undefined response');
-        }
-        this.relatedChanges = response?.changes ?? [];
-      }),
-      this.restApiService
-        .getChangesSubmittedTogether(change._number)
-        .then(response => {
-          this.submittedTogether = response;
-        }),
-      this.restApiService
-        .getChangeCherryPicks(change.project, change.change_id, change._number)
-        .then(response => {
-          this.cherryPickChanges = response || [];
-        }),
-    ];
-
-    // Get conflicts if change is open and is mergeable.
-    // Mergeable is output of restApiServict.getMergeable from gr-change-view
-    if (changeIsOpen(change) && this.mergeable) {
-      promises.push(
-        this.restApiService
-          .getChangeConflicts(change._number)
-          .then(response => {
-            this.conflictingChanges = response ?? [];
-          })
-      );
-    }
-    if (change.topic) {
-      const changeTopic = change.topic;
-      promises.push(
-        this.restApiService.getConfig().then(config => {
-          if (config && !config.change.submit_whole_topic) {
-            return this.restApiService
-              .getChangesWithSameTopic(changeTopic, change._number)
-              .then(response => {
-                if (changeTopic === this.change?.topic) {
-                  this.sameTopicChanges = response ?? [];
-                }
-              });
-          }
-          this.sameTopicChanges = [];
-          return Promise.resolve();
-        })
-      );
-    }
-
-    return Promise.all(promises);
-  }
-
-  /**
-   * Do the given objects describe the same change? Compares the changes by
-   * their numbers.
-   */
-  _changesEqual(
-    a?: ChangeInfo | RelatedChangeAndCommitInfo,
-    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
-  ) {
-    const aNum = this._getChangeNumber(a);
-    const bNum = this._getChangeNumber(b);
-    return aNum === bNum;
-  }
-
-  /**
-   * Get the change number from either a ChangeInfo (such as those included in
-   * SubmittedTogetherInfo responses) or get the change number from a
-   * RelatedChangeAndCommitInfo (such as those included in a
-   * RelatedChangesInfo response).
-   */
-  _getChangeNumber(
-    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
-  ) {
-    // Default to 0 if change property is not defined.
-    if (!change) return 0;
-
-    if (isChangeInfo(change)) {
-      return change._number;
-    }
-    return change._change_number;
-  }
-
-  /*
-   * A list of commit ids connected to change to understand if other change
-   * is direct or indirect ancestor / descendant.
-   */
-  _computeConnectedRevisions(
-    change?: ParsedChangeInfo,
-    patchNum?: PatchSetNum,
-    relatedChanges?: RelatedChangeAndCommitInfo[]
-  ) {
-    if (!patchNum || !relatedChanges || !change) {
-      return [];
-    }
-
-    const connected: CommitId[] = [];
-    const changeRevision = getRevisionKey(change, patchNum);
-    const commits = relatedChanges.map(c => c.commit);
-    let pos = commits.length - 1;
-
-    while (pos >= 0) {
-      const commit: CommitId = commits[pos].commit;
-      connected.push(commit);
-      // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
-      // eslint-disable-next-line eqeqeq
-      if (commit == changeRevision) {
-        break;
-      }
-      pos--;
-    }
-    while (pos >= 0) {
-      for (let i = 0; i < commits[pos].parents.length; i++) {
-        if (connected.includes(commits[pos].parents[i].commit)) {
-          connected.push(commits[pos].commit);
-          break;
-        }
-      }
-      --pos;
-    }
-    return connected;
-  }
-}
-
-@customElement('gr-related-collapse')
-export class GrRelatedCollapse extends GrLitElement {
-  @property()
-  title = '';
-
-  @property()
-  showAll = false;
-
-  @property()
-  length = 0;
-
-  @property()
-  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
-
-  private readonly reporting = appContext.reportingService;
-
-  static get styles() {
-    return [
-      sharedStyles,
-      css`
-        .title {
-          font-weight: var(--font-weight-bold);
-          color: var(--deemphasized-text-color);
-          padding-left: var(--metadata-horizontal-padding);
-        }
-        h4 {
-          display: flex;
-          align-self: flex-end;
-        }
-        gr-button {
-          display: flex;
-        }
-        /* This is a hacky solution from old gr-related-change-list
-         * TODO(milutin): find layout without needing it
-         */
-        h4:before,
-        gr-button:before,
-        ::slotted(gr-related-change):before {
-          content: ' ';
-          flex-shrink: 0;
-          width: 1.2em;
-        }
-        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
-          visibility: visible;
-          height: auto;
-        }
-        .collapsed ::slotted(.marker) {
-          display: block;
-        }
-        .show-all ::slotted(.marker) {
-          display: none;
-        }
-        /* keep width, so width of section and position of show all button
-         * are set according to width of all (even hidden) elements
-         */
-        .collapsed ::slotted(gr-related-change) {
-          visibility: hidden;
-          height: 0px;
-        }
-        ::slotted(gr-related-change) {
-          visibility: visible;
-          height: auto;
-        }
-        gr-button iron-icon {
-          color: inherit;
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
-        }
-        .container {
-          justify-content: space-between;
-          display: flex;
-          margin-bottom: var(--spacing-s);
-        }
-        :host(.first) .container {
-          margin-bottom: var(--spacing-m);
-        }
-      `,
-    ];
-  }
-
-  render() {
-    const title = html`<h4 class="title">${this.title}</h4>`;
-
-    const collapsible = this.length > this.numChangesWhenCollapsed;
-    const items = html` <div
-      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
-    >
-      <slot></slot>
-    </div>`;
-
-    let button: TemplateResult | typeof nothing = nothing;
-    if (collapsible) {
-      let buttonText = 'Show less';
-      let buttonIcon = 'expand-less';
-      if (!this.showAll) {
-        buttonText = `Show all (${this.length})`;
-        buttonIcon = 'expand-more';
-      }
-      button = html`<gr-button link="" @click="${this.toggle}"
-        >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
-      ></gr-button>`;
-    }
-
-    return html`<div class="container">${title}${button}</div>
-      ${items}`;
-  }
-
-  private toggle(e: MouseEvent) {
-    e.stopPropagation();
-    this.showAll = !this.showAll;
-    this.reporting.reportInteraction('toggle show all button', {
-      sectionName: this.title,
-      toState: this.showAll ? 'Show all' : 'Show less',
-    });
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-related-changes-list-experimental': GrRelatedChangesListExperimental;
-    'gr-related-collapse': GrRelatedCollapse;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts
deleted file mode 100644
index 971da40..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts
+++ /dev/null
@@ -1,630 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
-import {PluginApi} from '../../../api/plugin';
-import {ChangeStatus} from '../../../constants/constants';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import '../../../test/common-test-setup-karma';
-import {
-  createChange,
-  createCommitInfoWithRequiredCommit,
-  createParsedChange,
-  createRelatedChangeAndCommitInfo,
-  createRelatedChangesInfo,
-  createRevision,
-  createSubmittedTogetherInfo,
-} from '../../../test/test-data-generators';
-import {
-  queryAndAssert,
-  resetPlugins,
-  stubRestApi,
-} from '../../../test/test-utils';
-import {
-  ChangeId,
-  ChangeInfo,
-  CommitId,
-  NumericChangeId,
-  PatchSetNum,
-  RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
-  SubmittedTogetherInfo,
-} from '../../../types/common';
-import {ParsedChangeInfo} from '../../../types/types';
-import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import './gr-related-changes-list-experimental';
-import {
-  ChangeMarkersInList,
-  GrRelatedChangesListExperimental,
-  GrRelatedCollapse,
-  Section,
-} from './gr-related-changes-list-experimental';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromElement('gr-related-changes-list-experimental');
-
-suite('gr-related-changes-list-experimental', () => {
-  let element: GrRelatedChangesListExperimental;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('show when collapsed', () => {
-    function genBoolArray(
-      instructions: Array<{
-        len: number;
-        v: boolean;
-      }>
-    ) {
-      return instructions
-        .map(inst => Array.from({length: inst.len}, () => inst.v))
-        .reduce((acc, val) => acc.concat(val), []);
-    }
-
-    function checkShowWhenCollapsed(
-      expected: boolean[],
-      markersPredicate: (index: number) => ChangeMarkersInList,
-      msg: string
-    ) {
-      for (let i = 0; i < expected.length; i++) {
-        assert.equal(
-          markersPredicate(i).showWhenCollapsed,
-          expected[i],
-          `change on pos (${i}) ${msg}`
-        );
-      }
-    }
-
-    test('size 5', () => {
-      const markersPredicate = element.markersPredicateFactory(10, 4, 5);
-      const expectedCollapsing = genBoolArray([
-        {len: 2, v: false},
-        {len: 5, v: true},
-        {len: 3, v: false},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing,
-        markersPredicate,
-        'highlight 4, size 10, size 5'
-      );
-
-      const markersPredicate2 = element.markersPredicateFactory(10, 8, 5);
-      const expectedCollapsing2 = genBoolArray([
-        {len: 5, v: false},
-        {len: 5, v: true},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing2,
-        markersPredicate2,
-        'highlight 8, size 10, size 5'
-      );
-
-      const markersPredicate3 = element.markersPredicateFactory(10, 1, 5);
-      const expectedCollapsing3 = genBoolArray([
-        {len: 5, v: true},
-        {len: 5, v: false},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing3,
-        markersPredicate3,
-        'highlight 1, size 10, size 5'
-      );
-    });
-
-    test('size 4', () => {
-      const markersPredicate = element.markersPredicateFactory(10, 4, 4);
-      const expectedCollapsing = genBoolArray([
-        {len: 2, v: false},
-        {len: 4, v: true},
-        {len: 4, v: false},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing,
-        markersPredicate,
-        'highlight 4, len 10, size 4'
-      );
-
-      const markersPredicate2 = element.markersPredicateFactory(10, 8, 4);
-      const expectedCollapsing2 = genBoolArray([
-        {len: 6, v: false},
-        {len: 4, v: true},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing2,
-        markersPredicate2,
-        'highlight 8, len 10, size 4'
-      );
-
-      const markersPredicate3 = element.markersPredicateFactory(10, 1, 4);
-      const expectedCollapsing3 = genBoolArray([
-        {len: 4, v: true},
-        {len: 6, v: false},
-      ]);
-      checkShowWhenCollapsed(
-        expectedCollapsing3,
-        markersPredicate3,
-        'highlight 1, len 10, size 4'
-      );
-    });
-  });
-
-  suite('section size', () => {
-    test('1 section', () => {
-      const sectionSize = element.sectionSizeFactory(20, 0, 0, 0, 0);
-      assert.equal(sectionSize(Section.RELATED_CHANGES), 15);
-      const sectionSize2 = element.sectionSizeFactory(0, 0, 10, 0, 0);
-      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
-    });
-    test('2 sections', () => {
-      const sectionSize = element.sectionSizeFactory(20, 20, 0, 0, 0);
-      assert.equal(sectionSize(Section.RELATED_CHANGES), 11);
-      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
-      const sectionSize2 = element.sectionSizeFactory(4, 0, 10, 0, 0);
-      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
-      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
-    });
-    test('many sections', () => {
-      const sectionSize = element.sectionSizeFactory(20, 20, 3, 3, 3);
-      assert.equal(sectionSize(Section.RELATED_CHANGES), 3);
-      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
-      const sectionSize2 = element.sectionSizeFactory(4, 1, 10, 1, 1);
-      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
-      assert.equal(sectionSize2(Section.SAME_TOPIC), 4);
-    });
-  });
-
-  suite('test first non-empty list', () => {
-    const relatedChangeInfo: RelatedChangesInfo = {
-      ...createRelatedChangesInfo(),
-      changes: [createRelatedChangeAndCommitInfo()],
-    };
-    const submittedTogether: SubmittedTogetherInfo = {
-      ...createSubmittedTogetherInfo(),
-      changes: [createChange()],
-    };
-
-    setup(() => {
-      element.change = createParsedChange();
-      element.patchNum = 1 as PatchSetNum;
-    });
-
-    test('first list', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      await element.reload();
-      const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        section,
-        'gr-related-collapse'
-      );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
-    });
-
-    test('first empty second non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(createRelatedChangesInfo())
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(submittedTogether)
-      );
-      await element.reload();
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(relatedChanges!.classList.contains('first'));
-      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
-        'gr-related-collapse'
-      );
-      assert.isTrue(submittedTogetherSection!.classList.contains('first'));
-    });
-
-    test('first non-empty second empty third non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(createSubmittedTogetherInfo())
-      );
-      stubRestApi('getChangeCherryPicks').returns(
-        Promise.resolve([createChange()])
-      );
-      await element.reload();
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
-        'gr-related-collapse'
-      );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
-      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
-      const cherryPicks = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#cherryPicks'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(cherryPicks!.classList.contains('first'));
-    });
-  });
-
-  test('_changesEqual', () => {
-    const change1: ChangeInfo = {
-      ...createChange(),
-      change_id: '123' as ChangeId,
-      _number: 0 as NumericChangeId,
-    };
-    const change2: ChangeInfo = {
-      ...createChange(),
-      change_id: '456' as ChangeId,
-      _number: 1 as NumericChangeId,
-    };
-    const change3: ChangeInfo = {
-      ...createChange(),
-      change_id: '123' as ChangeId,
-      _number: 2 as NumericChangeId,
-    };
-    const change4: RelatedChangeAndCommitInfo = {
-      ...createRelatedChangeAndCommitInfo(),
-      change_id: '123' as ChangeId,
-      _change_number: 1 as NumericChangeId,
-    };
-
-    assert.isTrue(element._changesEqual(change1, change1));
-    assert.isFalse(element._changesEqual(change1, change2));
-    assert.isFalse(element._changesEqual(change1, change3));
-    assert.isTrue(element._changesEqual(change2, change4));
-  });
-
-  test('_getChangeNumber', () => {
-    const change1: ChangeInfo = {
-      ...createChange(),
-      change_id: '123' as ChangeId,
-      _number: 0 as NumericChangeId,
-    };
-    const change2: ChangeInfo = {
-      ...createChange(),
-      change_id: '456' as ChangeId,
-      _number: 1 as NumericChangeId,
-    };
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
-  });
-
-  suite('get conflicts tests', () => {
-    let element: GrRelatedChangesListExperimental;
-    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      conflictsStub = stubRestApi('getChangeConflicts').returns(
-        Promise.resolve(undefined)
-      );
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.patchNum = 7 as PatchSetNum;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.patchNum = 7 as PatchSetNum;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.patchNum = 7 as PatchSetNum;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.patchNum = 7 as PatchSetNum;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
-  test('connected revisions', () => {
-    const change: ParsedChangeInfo = {
-      ...createParsedChange(),
-      revisions: {
-        e3c6d60783bfdec9ebae7dcfec4662360433449e: createRevision(1),
-        '26e5e4c9c7ae31cbd876271cca281ce22b413997': createRevision(2),
-        bf7884d695296ca0c91702ba3e2bc8df0f69a907: createRevision(7),
-        b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3: createRevision(5),
-        d6bcee67570859ccb684873a85cf50b1f0e96fda: createRevision(6),
-        cc960918a7f90388f4a9e05753d0f7b90ad44546: createRevision(3),
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': createRevision(4),
-      },
-    };
-    let patchNum = 7 as PatchSetNum;
-    let relatedChanges: RelatedChangeAndCommitInfo[] = [
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
-          ),
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
-              subject: 'subject1',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-          ),
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
-              subject: 'subject2',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-          ),
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
-              subject: 'subject3',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-          ),
-          parents: [
-            {
-              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' as CommitId,
-              subject: 'subject4',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
-          ),
-          parents: [
-            {
-              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' as CommitId,
-              subject: 'subject5',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '613bc4f81741a559c6667ac08d71dcc3348f73ce'
-          ),
-          parents: [
-            {
-              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' as CommitId,
-              subject: 'subject6',
-            },
-          ],
-        },
-      },
-    ];
-
-    let connectedChanges = element._computeConnectedRevisions(
-      change,
-      patchNum,
-      relatedChanges
-    );
-    assert.deepEqual(connectedChanges, [
-      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-    ]);
-
-    patchNum = 4 as PatchSetNum;
-    relatedChanges = [
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
-          ),
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-          ),
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-          ),
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b'
-          ),
-          parents: [
-            {
-              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
-          ),
-          parents: [
-            {
-              commit: 'af815dac54318826b7f1fa468acc76349ffc588e' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-      {
-        ...createRelatedChangeAndCommitInfo(),
-        commit: {
-          ...createCommitInfoWithRequiredCommit(
-            'af815dac54318826b7f1fa468acc76349ffc588e'
-          ),
-          parents: [
-            {
-              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' as CommitId,
-              subject: 'My parent commit',
-            },
-          ],
-        },
-      },
-    ];
-
-    connectedChanges = element._computeConnectedRevisions(
-      change,
-      patchNum,
-      relatedChanges
-    );
-    assert.deepEqual(connectedChanges, [
-      'af815dac54318826b7f1fa468acc76349ffc588e',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-    ]);
-  });
-
-  suite('gr-related-changes-list plugin tests', () => {
-    let element: GrRelatedChangesListExperimental;
-
-    setup(() => {
-      resetPlugins();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      resetPlugins();
-    });
-
-    test('endpoint params', done => {
-      element.change = {...createParsedChange(), labels: {}};
-      interface RelatedChangesListGrEndpointDecorator
-        extends GrEndpointDecorator {
-        plugin: PluginApi;
-        change: ParsedChangeInfo;
-      }
-      let hookEl: RelatedChangesListGrEndpointDecorator;
-      let plugin: PluginApi;
-      pluginApi.install(
-        p => {
-          plugin = p;
-          plugin
-            .hook('related-changes-section')
-            .getLastAttached()
-            .then(el => (hookEl = el as RelatedChangesListGrEndpointDecorator));
-        },
-        '0.1',
-        'http://some/plugins/url1.js'
-      );
-      getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        done();
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
similarity index 100%
rename from polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
rename to polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 927f3c9..8d5bc33 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2021 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,217 +14,547 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
+import {html, nothing} from 'lit-html';
+import './gr-related-change';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-related-changes-list_html';
+import {classMap} from 'lit-html/directives/class-map';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {
+  customElement,
+  property,
+  css,
+  internalProperty,
+  TemplateResult,
+} from 'lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  SubmittedTogetherInfo,
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
+  PatchSetNum,
+  CommitId,
+} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {ChangeStatus} from '../../../constants/constants';
-
+import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
   getRevisionKey,
   isChangeInfo,
 } from '../../../utils/change-util';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {customElement, observe, property} from '@polymer/decorators';
-import {
-  ChangeId,
-  ChangeInfo,
-  CommitId,
-  NumericChangeId,
-  PatchSetNum,
-  RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
-  RepoName,
-  SubmittedTogetherInfo,
-} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {pluralize} from '../../../utils/string-util';
-import {ParsedChangeInfo} from '../../../types/types';
 
-function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
-  return {changes: [], non_visible_changes: 0};
+/** What is the maximum number of shown changes in collapsed list? */
+const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+
+export interface ChangeMarkersInList {
+  showCurrentChangeArrow: boolean;
+  showWhenCollapsed: boolean;
+  showTopArrow: boolean;
+  showBottomArrow: boolean;
+}
+
+export enum Section {
+  RELATED_CHANGES = 'related changes',
+  SUBMITTED_TOGETHER = 'submitted together',
+  SAME_TOPIC = 'same topic',
+  MERGE_CONFLICTS = 'merge conflicts',
+  CHERRY_PICKS = 'cherry picks',
 }
 
 @customElement('gr-related-changes-list')
-export class GrRelatedChangesList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when a new section is loaded so that the change view can determine
-   * a show more button is needed, sometimes before all the sections finish
-   * loading.
-   *
-   * @event new-section-loaded
-   */
-
-  @property({type: Object})
+export class GrRelatedChangesList extends GrLitElement {
+  @property()
   change?: ParsedChangeInfo;
 
-  @property({type: Boolean, notify: true})
-  hasParent = false;
-
   @property({type: String})
   patchNum?: PatchSetNum;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  hidden = false;
-
-  @property({type: Boolean, notify: true})
-  loading?: boolean;
-
-  @property({type: Boolean})
+  @property()
   mergeable?: boolean;
 
-  @property({
-    type: Array,
-    computed:
-      '_computeConnectedRevisions(change, patchNum, ' +
-      '_relatedResponse.changes)',
-  })
-  _connectedRevisions?: CommitId[];
+  @internalProperty()
+  submittedTogether?: SubmittedTogetherInfo = {
+    changes: [],
+    non_visible_changes: 0,
+  };
 
-  @property({type: Object})
-  _relatedResponse: RelatedChangesInfo = {changes: []};
+  @internalProperty()
+  relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-  @property({type: Object})
-  _submittedTogether?: SubmittedTogetherInfo = getEmptySubmitTogetherInfo();
+  @internalProperty()
+  conflictingChanges: ChangeInfo[] = [];
 
-  @property({type: Array})
-  _conflicts: ChangeInfo[] = [];
+  @internalProperty()
+  cherryPickChanges: ChangeInfo[] = [];
 
-  @property({type: Array})
-  _cherryPicks: ChangeInfo[] = [];
-
-  @property({type: Array})
-  _sameTopic?: ChangeInfo[] = [];
+  @internalProperty()
+  sameTopicChanges: ChangeInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly reportingService = appContext.reportingService;
-
-  clear() {
-    this.loading = true;
-    this.hidden = true;
-
-    this._relatedResponse = {changes: []};
-    this._submittedTogether = getEmptySubmitTogetherInfo();
-    this._conflicts = [];
-    this._cherryPicks = [];
-    this._sameTopic = [];
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .note {
+          color: var(--error-text-color);
+          margin-left: 1.2em;
+        }
+        section {
+          margin-bottom: var(--spacing-l);
+        }
+        gr-related-change {
+          display: flex;
+        }
+        .marker {
+          position: absolute;
+          margin-left: calc(-1 * var(--spacing-s));
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
+      `,
+    ];
   }
 
-  reload() {
-    if (!this.change || !this.patchNum) {
-      return Promise.resolve();
+  render() {
+    const sectionSize = this.sectionSizeFactory(
+      this.relatedChanges.length,
+      this.submittedTogether?.changes.length || 0,
+      this.sameTopicChanges.length,
+      this.conflictingChanges.length,
+      this.cherryPickChanges.length
+    );
+    const relatedChangesMarkersPredicate = this.markersPredicateFactory(
+      this.relatedChanges.length,
+      this.relatedChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      ),
+      sectionSize(Section.RELATED_CHANGES)
+    );
+    const connectedRevisions = this._computeConnectedRevisions(
+      this.change,
+      this.patchNum,
+      this.relatedChanges
+    );
+    let firstNonEmptySectionFound = false;
+    let isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.relatedChanges.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const relatedChangeSection = html` <section
+      id="relatedChanges"
+      ?hidden=${!this.relatedChanges.length}
+    >
+      <gr-related-collapse
+        title="Relation chain"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.relatedChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
+      >
+        ${this.relatedChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                relatedChangesMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .connectedRevisions="${connectedRevisions}"
+                .href="${change?._change_number
+                  ? GerritNav.getUrlForChangeById(
+                      change._change_number,
+                      change.project,
+                      change._revision_number as PatchSetNum
+                    )
+                  : ''}"
+                .showChangeStatus=${true}
+                >${change.commit.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    const countNonVisibleChanges =
+      this.submittedTogether?.non_visible_changes ?? 0;
+    const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
+      submittedTogetherChanges.length,
+      submittedTogetherChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      ),
+      sectionSize(Section.SUBMITTED_TOGETHER)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound &&
+      (!!submittedTogetherChanges?.length ||
+        !!this.submittedTogether?.non_visible_changes);
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const submittedTogetherSection = html`<section
+      id="submittedTogether"
+      ?hidden=${!submittedTogetherChanges?.length &&
+      !this.submittedTogether?.non_visible_changes}
+    >
+      <gr-related-collapse
+        title="Submitted together"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${submittedTogetherChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
+      >
+        ${submittedTogetherChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                submittedTogetherMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
+                    index
+                  ).showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                .showSubmittableCheck=${true}
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+      <div class="note" ?hidden=${!countNonVisibleChanges}>
+        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
+      </div>
+    </section>`;
+
+    const sameTopicMarkersPredicate = this.markersPredicateFactory(
+      this.sameTopicChanges.length,
+      -1,
+      sectionSize(Section.SAME_TOPIC)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const sameTopicSection = html`<section
+      id="sameTopic"
+      ?hidden=${!this.sameTopicChanges?.length}
+    >
+      <gr-related-collapse
+        title="Same topic"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.sameTopicChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
+      >
+        ${this.sameTopicChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                sameTopicMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
+      this.conflictingChanges.length,
+      -1,
+      sectionSize(Section.MERGE_CONFLICTS)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const mergeConflictsSection = html`<section
+      id="mergeConflicts"
+      ?hidden=${!this.conflictingChanges?.length}
+    >
+      <gr-related-collapse
+        title="Merge conflicts"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.conflictingChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
+      >
+        ${this.conflictingChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                mergeConflictsMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const cherryPicksMarkersPredicate = this.markersPredicateFactory(
+      this.cherryPickChanges.length,
+      -1,
+      sectionSize(Section.CHERRY_PICKS)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const cherryPicksSection = html`<section
+      id="cherryPicks"
+      ?hidden=${!this.cherryPickChanges?.length}
+    >
+      <gr-related-collapse
+        title="Cherry picks"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.cherryPickChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
+      >
+        ${this.cherryPickChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                cherryPicksMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.branch}: ${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
+      ${mergeConflictsSection} ${cherryPicksSection}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  sectionSizeFactory(
+    relatedChangesLen: number,
+    submittedTogetherLen: number,
+    sameTopicLen: number,
+    mergeConflictsLen: number,
+    cherryPicksLen: number
+  ) {
+    const calcDefaultSize = (length: number) =>
+      Math.min(length, DEFALT_NUM_CHANGES_WHEN_COLLAPSED);
+
+    const sectionSizes = [
+      {
+        section: Section.RELATED_CHANGES,
+        size: calcDefaultSize(relatedChangesLen),
+        len: relatedChangesLen,
+      },
+      {
+        section: Section.SUBMITTED_TOGETHER,
+        size: calcDefaultSize(submittedTogetherLen),
+        len: submittedTogetherLen,
+      },
+      {
+        section: Section.SAME_TOPIC,
+        size: calcDefaultSize(sameTopicLen),
+        len: sameTopicLen,
+      },
+      {
+        section: Section.MERGE_CONFLICTS,
+        size: calcDefaultSize(mergeConflictsLen),
+        len: mergeConflictsLen,
+      },
+      {
+        section: Section.CHERRY_PICKS,
+        size: calcDefaultSize(cherryPicksLen),
+        len: cherryPicksLen,
+      },
+    ];
+
+    const FILLER = 1; // space for header
+    let totalSize = sectionSizes.reduce(
+      (acc, val) => acc + val.size + (val.size !== 0 ? FILLER : 0),
+      0
+    );
+
+    const MAX_SIZE = 16;
+    for (let i = 0; i < sectionSizes.length; i++) {
+      if (totalSize >= MAX_SIZE) break;
+      const sizeObj = sectionSizes[i];
+      if (sizeObj.size === sizeObj.len) continue;
+      const newSize = Math.min(
+        MAX_SIZE - totalSize + sizeObj.size,
+        sizeObj.len
+      );
+      totalSize += newSize - sizeObj.size;
+      sizeObj.size = newSize;
     }
+
+    return (section: Section) => {
+      const sizeObj = sectionSizes.find(sizeObj => sizeObj.section === section);
+      if (sizeObj) return sizeObj.size;
+      return DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+    };
+  }
+
+  markersPredicateFactory(
+    length: number,
+    highlightIndex: number,
+    numChangesShownWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED
+  ): (index: number) => ChangeMarkersInList {
+    const showWhenCollapsedPredicate = (index: number) => {
+      if (highlightIndex === -1) return index < numChangesShownWhenCollapsed;
+      if (highlightIndex === 0)
+        return index <= numChangesShownWhenCollapsed - 1;
+      if (highlightIndex === length - 1)
+        return index >= length - numChangesShownWhenCollapsed;
+      let numBeforeHighlight = Math.floor(numChangesShownWhenCollapsed / 2);
+      let numAfterHighlight =
+        Math.floor(numChangesShownWhenCollapsed / 2) -
+        (numChangesShownWhenCollapsed % 2 ? 0 : 1);
+      numBeforeHighlight += Math.max(
+        highlightIndex + numAfterHighlight - length + 1,
+        0
+      );
+      numAfterHighlight -= Math.min(0, highlightIndex - numBeforeHighlight);
+      return (
+        highlightIndex - numBeforeHighlight <= index &&
+        index <= highlightIndex + numAfterHighlight
+      );
+    };
+    return (index: number) => {
+      return {
+        showCurrentChangeArrow:
+          highlightIndex !== -1 && index === highlightIndex,
+        showWhenCollapsed: showWhenCollapsedPredicate(index),
+        showTopArrow:
+          index >= 1 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index - 1),
+        showBottomArrow:
+          index <= length - 2 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index + 1),
+      };
+    };
+  }
+
+  renderMarkers(changeMarkers: ChangeMarkersInList) {
+    if (changeMarkers.showCurrentChangeArrow) {
+      return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        >âž”</span
+      >`;
+    }
+    if (changeMarkers.showTopArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed ancestors"
+        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
+      ></span> `;
+    }
+    if (changeMarkers.showBottomArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed descendants"
+        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
+      ></span> `;
+    }
+    return nothing;
+  }
+
+  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
     const change = this.change;
-    this.loading = true;
+    if (!change) return Promise.reject(new Error('change missing'));
+    if (!this.patchNum) return Promise.reject(new Error('patchNum missing'));
+    if (!getRelatedChanges) {
+      getRelatedChanges = this.restApiService.getRelatedChanges(
+        change._number,
+        this.patchNum
+      );
+    }
     const promises: Array<Promise<void>> = [
-      this.restApiService
-        .getRelatedChanges(change._number, this.patchNum)
-        .then(response => {
-          if (!response) {
-            throw new Error('getRelatedChanges returned undefined response');
-          }
-          this._relatedResponse = response;
-          this._fireReloadEvent();
-          this.hasParent = this._calculateHasParent(
-            change.change_id,
-            response.changes
-          );
-        }),
+      getRelatedChanges.then(response => {
+        if (!response) {
+          throw new Error('getRelatedChanges returned undefined response');
+        }
+        this.relatedChanges = response?.changes ?? [];
+      }),
       this.restApiService
         .getChangesSubmittedTogether(change._number)
         .then(response => {
-          this._submittedTogether = response;
-          this._fireReloadEvent();
+          this.submittedTogether = response;
         }),
       this.restApiService
         .getChangeCherryPicks(change.project, change.change_id, change._number)
         .then(response => {
-          this._cherryPicks = response || [];
-          this._fireReloadEvent();
+          this.cherryPickChanges = response || [];
         }),
     ];
 
     // Get conflicts if change is open and is mergeable.
+    // Mergeable is output of restApiServict.getMergeable from gr-change-view
     if (changeIsOpen(change) && this.mergeable) {
       promises.push(
         this.restApiService
           .getChangeConflicts(change._number)
           .then(response => {
-            // Because the server doesn't always return a response and the
-            // template expects an array, always return an array.
-            this._conflicts = response ? response : [];
-            this._fireReloadEvent();
+            this.conflictingChanges = response ?? [];
           })
       );
     }
-
-    promises.push(
-      this._getServerConfig().then(config => {
-        if (change.topic) {
-          if (!config) {
-            throw new Error('_getServerConfig returned undefined ');
-          }
-          if (!config.change.submit_whole_topic) {
+    if (change.topic) {
+      const changeTopic = change.topic;
+      promises.push(
+        this.restApiService.getConfig().then(config => {
+          if (config && !config.change.submit_whole_topic) {
             return this.restApiService
-              .getChangesWithSameTopic(change.topic, change._number)
+              .getChangesWithSameTopic(changeTopic, change._number)
               .then(response => {
-                this._sameTopic = response;
+                if (changeTopic === this.change?.topic) {
+                  this.sameTopicChanges = response ?? [];
+                }
               });
           }
-        }
-        this._sameTopic = [];
-        return Promise.resolve();
-      })
-    );
+          this.sameTopicChanges = [];
+          return Promise.resolve();
+        })
+      );
+    }
 
-    return Promise.all(promises).then(() => {
-      this.loading = false;
-    });
-  }
-
-  _fireReloadEvent() {
-    // The listener on the change computes height of the related changes
-    // section, so they have to be rendered first, and inside a dom-repeat,
-    // that requires a flush.
-    flush();
-    this.dispatchEvent(new CustomEvent('new-section-loaded'));
-  }
-
-  /**
-   * Determines whether or not the given change has a parent change. If there
-   * is a relation chain, and the change id is not the last item of the
-   * relation chain, there is a parent.
-   */
-  _calculateHasParent(
-    currentChangeId: ChangeId,
-    relatedChanges: RelatedChangeAndCommitInfo[]
-  ) {
-    return (
-      relatedChanges.length > 0 &&
-      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
-    );
-  }
-
-  _getServerConfig() {
-    return this.restApiService.getConfig();
-  }
-
-  _computeChangeURL(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    patchNum?: PatchSetNum
-  ) {
-    return GerritNav.getUrlForChangeById(changeNum, project, patchNum);
+    return Promise.all(promises);
   }
 
   /**
@@ -232,8 +562,8 @@
    * their numbers.
    */
   _changesEqual(
-    a: ChangeInfo | RelatedChangeAndCommitInfo,
-    b: ChangeInfo | RelatedChangeAndCommitInfo
+    a?: ChangeInfo | RelatedChangeAndCommitInfo,
+    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
   ) {
     const aNum = this._getChangeNumber(a);
     const bNum = this._getChangeNumber(b);
@@ -246,7 +576,9 @@
    * RelatedChangeAndCommitInfo (such as those included in a
    * RelatedChangesInfo response).
    */
-  _getChangeNumber(change?: ChangeInfo | RelatedChangeAndCommitInfo) {
+  _getChangeNumber(
+    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
     // Default to 0 if change property is not defined.
     if (!change) return 0;
 
@@ -256,136 +588,10 @@
     return change._change_number;
   }
 
-  _computeLinkClass(change: ParsedChangeInfo) {
-    const statuses = [];
-    if (change.status === ChangeStatus.ABANDONED) {
-      statuses.push('strikethrough');
-    }
-    if (change.submittable) {
-      statuses.push('submittable');
-    }
-    return statuses.join(' ');
-  }
-
-  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
-    const classes = ['status'];
-    if (change._revision_number !== change._current_revision_number) {
-      classes.push('notCurrent');
-    } else if (this._isIndirectAncestor(change)) {
-      classes.push('indirectAncestor');
-    } else if (change.submittable) {
-      classes.push('submittable');
-    } else if (change.status === ChangeStatus.NEW) {
-      classes.push('hidden');
-    }
-    return classes.join(' ');
-  }
-
-  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
-    switch (change.status) {
-      case ChangeStatus.MERGED:
-        return 'Merged';
-      case ChangeStatus.ABANDONED:
-        return 'Abandoned';
-    }
-    if (change._revision_number !== change._current_revision_number) {
-      return 'Not current';
-    } else if (this._isIndirectAncestor(change)) {
-      return 'Indirect ancestor';
-    } else if (change.submittable) {
-      return 'Submittable';
-    }
-    return '';
-  }
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    // We listen to `new-section-loaded` events to allow plugins to trigger
-    // visibility computations, if their content or visibility changed.
-    this.addEventListener('new-section-loaded', () =>
-      this._handleNewSectionLoaded()
-    );
-  }
-
-  _handleNewSectionLoaded() {
-    // A plugin sent a `new-section-loaded` event, so its visibility likely
-    // changed. Hence, we update our visibility if needed.
-    this._resultsChanged(
-      this._relatedResponse,
-      this._submittedTogether,
-      this._conflicts,
-      this._cherryPicks,
-      this._sameTopic
-    );
-  }
-
-  @observe(
-    '_relatedResponse',
-    '_submittedTogether',
-    '_conflicts',
-    '_cherryPicks',
-    '_sameTopic'
-  )
-  _resultsChanged(
-    related: RelatedChangesInfo,
-    submittedTogether: SubmittedTogetherInfo | undefined,
-    conflicts: ChangeInfo[],
-    cherryPicks: ChangeInfo[],
-    sameTopic?: ChangeInfo[]
-  ) {
-    if (!submittedTogether || !sameTopic) {
-      return;
-    }
-    const submittedTogetherChangesCount =
-      (submittedTogether.changes || []).length +
-      (submittedTogether.non_visible_changes || 0);
-    const results = [
-      related && related.changes,
-      // If there are either visible or non-visible changes, we need a
-      // non-empty list to fire the event and set visibility.
-      submittedTogetherChangesCount ? [{}] : [],
-      conflicts,
-      cherryPicks,
-      sameTopic,
-    ];
-    for (let i = 0; i < results.length; i++) {
-      if (results[i] && results[i].length > 0) {
-        this.hidden = false;
-        this.dispatchEvent(
-          new CustomEvent('update', {
-            composed: true,
-            bubbles: false,
-          })
-        );
-        return;
-      }
-    }
-
-    this._computeHidden();
-  }
-
-  _computeHidden() {
-    // None of the built-in change lists had elements. So all of them are
-    // hidden. But since plugins might have injected visible content, we need
-    // to check for that and stay visible if we find any such visible content.
-    // (We consider plugins visible except if it's main element has the hidden
-    // attribute set to true.)
-    const plugins = getPluginEndpoints().getDetails('related-changes-section');
-    this.hidden = !plugins.some(
-      plugin =>
-        !plugin.domHook ||
-        plugin.domHook.getAllAttached().some(instance => !instance.hidden)
-    );
-  }
-
-  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
-    return (
-      this._connectedRevisions &&
-      !this._connectedRevisions.includes(change.commit.commit)
-    );
-  }
-
+  /*
+   * A list of commit ids connected to change to understand if other change
+   * is direct or indirect ancestor / descendant.
+   */
   _computeConnectedRevisions(
     change?: ParsedChangeInfo,
     patchNum?: PatchSetNum,
@@ -421,40 +627,121 @@
     }
     return connected;
   }
+}
 
-  _computeSubmittedTogetherClass(submittedTogether?: SubmittedTogetherInfo) {
-    if (
-      !submittedTogether ||
-      (submittedTogether.changes.length === 0 &&
-        !submittedTogether.non_visible_changes)
-    ) {
-      return 'hidden';
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends GrLitElement {
+  @property()
+  title = '';
+
+  @property()
+  showAll = false;
+
+  @property()
+  length = 0;
+
+  @property()
+  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .title {
+          font-weight: var(--font-weight-bold);
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        h4 {
+          display: flex;
+          align-self: flex-end;
+        }
+        gr-button {
+          display: flex;
+        }
+        /* This is a hacky solution from old gr-related-change-list
+         * TODO(milutin): find layout without needing it
+         */
+        h4:before,
+        gr-button:before,
+        ::slotted(gr-related-change):before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
+          visibility: visible;
+          height: auto;
+        }
+        .collapsed ::slotted(.marker) {
+          display: block;
+        }
+        .show-all ::slotted(.marker) {
+          display: none;
+        }
+        /* keep width, so width of section and position of show all button
+         * are set according to width of all (even hidden) elements
+         */
+        .collapsed ::slotted(gr-related-change) {
+          visibility: hidden;
+          height: 0px;
+        }
+        ::slotted(gr-related-change) {
+          visibility: visible;
+          height: auto;
+        }
+        gr-button iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .container {
+          justify-content: space-between;
+          display: flex;
+          margin-bottom: var(--spacing-s);
+        }
+        :host(.first) .container {
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const title = html`<h4 class="title">${this.title}</h4>`;
+
+    const collapsible = this.length > this.numChangesWhenCollapsed;
+    const items = html` <div
+      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
+    >
+      <slot></slot>
+    </div>`;
+
+    let button: TemplateResult | typeof nothing = nothing;
+    if (collapsible) {
+      let buttonText = 'Show less';
+      let buttonIcon = 'expand-less';
+      if (!this.showAll) {
+        buttonText = `Show all (${this.length})`;
+        buttonIcon = 'expand-more';
+      }
+      button = html`<gr-button link="" @click="${this.toggle}"
+        >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
+      ></gr-button>`;
     }
-    return '';
+
+    return html`<div class="container">${title}${button}</div>
+      ${items}`;
   }
 
-  _computeNonVisibleChangesNote(n: number) {
-    return `(+ ${pluralize(n, 'non-visible change')})`;
-  }
-
-  // TODO(milutin): Temporary for data collection, remove when data collected
-  _reportClick(e: Event) {
-    const target = e.target as HTMLAnchorElement | undefined;
-    const section = target?.parentElement?.parentElement;
-    const sectionName = section?.getElementsByTagName('h4')[0]?.innerText;
-    const sectionLinks = [...(section?.getElementsByTagName('a') ?? [])];
-    const currentChange = section
-      ?.getElementsByClassName('arrowToCurrentChange')[0]
-      ?.nextElementSibling?.nextElementSibling?.getElementsByTagName('a')[0];
-
-    if (!target) return;
-    this.reportingService.reportInteraction('related-change-click', {
-      sectionName,
-      index: sectionLinks.indexOf(target) + 1,
-      countChanges: sectionLinks.length,
-      currentChangeIndex: !currentChange
-        ? undefined
-        : sectionLinks.indexOf(currentChange) + 1,
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
     });
   }
 }
@@ -462,5 +749,6 @@
 declare global {
   interface HTMLElementTagNameMap {
     'gr-related-changes-list': GrRelatedChangesList;
+    'gr-related-collapse': GrRelatedCollapse;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
deleted file mode 100644
index 2f53319..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    section {
-      margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-    }
-    a {
-      display: block;
-    }
-    .changeContainer,
-    a {
-      max-width: 100%;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .changeContainer {
-      display: flex;
-    }
-    .arrowToCurrentChange {
-      position: absolute;
-    }
-    h4,
-    section div {
-      display: flex;
-    }
-    h4:before,
-    section div:before {
-      content: ' ';
-      flex-shrink: 0;
-      width: 1.2em;
-    }
-    .note {
-      color: var(--error-text-color);
-    }
-    .relatedChanges a {
-      display: inline-block;
-    }
-    .strikethrough {
-      color: var(--deemphasized-text-color);
-      text-decoration: line-through;
-    }
-    .status {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      margin-left: var(--spacing-xs);
-    }
-    .notCurrent {
-      color: var(--warning-foreground);
-    }
-    .indirectAncestor {
-      color: var(--indirect-ancestor-text-color);
-    }
-    .submittableCheck {
-      padding-left: var(--spacing-s);
-      color: var(--positive-green-text-color);
-      display: none;
-    }
-    .submittableCheck.submittable {
-      display: inline;
-    }
-    .hidden,
-    .mobile {
-      display: none;
-    }
-    @media screen and (max-width: 60em) {
-      .mobile {
-        display: block;
-      }
-    }
-  </style>
-  <div>
-    <gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      <section
-        class="relatedChanges"
-        hidden$="[[!_relatedResponse.changes.length]]"
-        hidden=""
-      >
-        <h4>Relation chain</h4>
-        <template
-          is="dom-repeat"
-          items="[[_relatedResponse.changes]]"
-          as="related"
-        >
-          <template is="dom-if" if="[[_changesEqual(related, change)]]">
-            <span
-              role="img"
-              class="arrowToCurrentChange"
-              aria-label="Arrow marking current change"
-              >âž”</span
-            >
-          </template>
-          <div class="rightIndent changeContainer">
-            <a
-              href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.commit.subject]]"
-              on-click="_reportClick"
-            >
-              [[related.commit.subject]]
-            </a>
-            <span class$="[[_computeChangeStatusClass(related)]]">
-              ([[_computeChangeStatus(related)]])
-            </span>
-          </div>
-        </template>
-      </section>
-      <section
-        id="submittedTogether"
-        class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]"
-      >
-        <h4>Submitted together</h4>
-        <template
-          is="dom-repeat"
-          items="[[_submittedTogether.changes]]"
-          as="related"
-        >
-          <template is="dom-if" if="[[_changesEqual(related, change)]]">
-            <span
-              role="img"
-              class="arrowToCurrentChange"
-              aria-label="Arrow marking current change"
-              >âž”</span
-            >
-          </template>
-          <div class="changeContainer">
-            <a
-              href$="[[_computeChangeURL(related._number, related.project)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
-              on-click="_reportClick"
-            >
-              [[related.project]]: [[related.branch]]: [[related.subject]]
-            </a>
-            <span
-              tabindex="-1"
-              title="Submittable"
-              class$="submittableCheck [[_computeLinkClass(related)]]"
-              role="img"
-              aria-label="Submittable"
-              >✓</span
-            >
-          </div>
-        </template>
-        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
-          <div class="note">
-            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_sameTopic.length]]" hidden="">
-        <h4>Same topic</h4>
-        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
-              on-click="_reportClick"
-            >
-              [[change.project]]: [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_conflicts.length]]" hidden="">
-        <h4>Merge conflicts</h4>
-        <template is="dom-repeat" items="[[_conflicts]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.subject]]"
-              on-click="_reportClick"
-            >
-              [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_cherryPicks.length]]" hidden="">
-        <h4>Cherry picks</h4>
-        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.branch]]: [[change.subject]]"
-              on-click="_reportClick"
-            >
-              [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>
-  </div>
-  <div hidden$="[[!loading]]">Loading...</div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 631c077..ae9af4a 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2021 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -15,70 +15,314 @@
  * limitations under the License.
  */
 
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {PluginApi} from '../../../api/plugin';
 import {ChangeStatus} from '../../../constants/constants';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import '../../../test/common-test-setup-karma';
 import {
   createChange,
-  createCommit,
   createCommitInfoWithRequiredCommit,
   createParsedChange,
+  createRelatedChangeAndCommitInfo,
+  createRelatedChangesInfo,
+  createRevision,
+  createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
 import {
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
   ChangeId,
   ChangeInfo,
   CommitId,
   NumericChangeId,
   PatchSetNum,
   RelatedChangeAndCommitInfo,
-  RepoName,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
 } from '../../../types/common';
-import './gr-related-changes-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
-import {query, queryAndAssert} from '../../../test/test-utils';
-import {GrRelatedChangesList} from './gr-related-changes-list';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import './gr-related-changes-list';
+import {
+  ChangeMarkersInList,
+  GrRelatedChangesList,
+  GrRelatedCollapse,
+  Section,
+} from './gr-related-changes-list';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
 const basicFixture = fixtureFromElement('gr-related-changes-list');
 
-suite('gr-related-changes-list tests', () => {
+suite('gr-related-changes-list', () => {
   let element: GrRelatedChangesList;
 
   setup(() => {
-    // Since pluginEndpoints are global, must reset state.
-    _testOnly_resetEndpoints();
     element = basicFixture.instantiate();
   });
 
-  // obsolete
-  test('event for section loaded fires for each section ', () => {
-    const loadedStub = sinon.stub();
-    element.patchNum = 7 as PatchSetNum;
-    element.change = {
-      ...createParsedChange(),
-      change_id: '123' as ChangeId,
-      status: ChangeStatus.NEW,
-    };
-    element.mergeable = true;
-    element.addEventListener('new-section-loaded', loadedStub);
+  suite('show when collapsed', () => {
+    function genBoolArray(
+      instructions: Array<{
+        len: number;
+        v: boolean;
+      }>
+    ) {
+      return instructions
+        .map(inst => Array.from({length: inst.len}, () => inst.v))
+        .reduce((acc, val) => acc.concat(val), []);
+    }
 
-    return element.reload().then(() => {
-      assert.equal(loadedStub.callCount, 4);
+    function checkShowWhenCollapsed(
+      expected: boolean[],
+      markersPredicate: (index: number) => ChangeMarkersInList,
+      msg: string
+    ) {
+      for (let i = 0; i < expected.length; i++) {
+        assert.equal(
+          markersPredicate(i).showWhenCollapsed,
+          expected[i],
+          `change on pos (${i}) ${msg}`
+        );
+      }
+    }
+
+    test('size 5', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 5);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 5, v: true},
+        {len: 3, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, size 10, size 5'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 5);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 5, v: false},
+        {len: 5, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, size 10, size 5'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 5);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 5, v: true},
+        {len: 5, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, size 10, size 5'
+      );
+    });
+
+    test('size 4', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 4);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 4, v: true},
+        {len: 4, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, len 10, size 4'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 4);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 6, v: false},
+        {len: 4, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, len 10, size 4'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 4);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 4, v: true},
+        {len: 6, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, len 10, size 4'
+      );
     });
   });
 
-  // trivial
-  suite('getChangeConflicts resolves undefined', () => {
+  suite('section size', () => {
+    test('1 section', () => {
+      const sectionSize = element.sectionSizeFactory(20, 0, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 15);
+      const sectionSize2 = element.sectionSizeFactory(0, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('2 sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 11);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('many sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 3, 3, 3);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 3);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 1, 10, 1, 1);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 4);
+    });
+  });
+
+  suite('test first non-empty list', () => {
+    const relatedChangeInfo: RelatedChangesInfo = {
+      ...createRelatedChangesInfo(),
+      changes: [createRelatedChangeAndCommitInfo()],
+    };
+    const submittedTogether: SubmittedTogetherInfo = {
+      ...createSubmittedTogetherInfo(),
+      changes: [createChange()],
+    };
+
+    setup(() => {
+      element.change = createParsedChange();
+      element.patchNum = 1 as PatchSetNum;
+    });
+
+    test('first list', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      await element.reload();
+      const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        section,
+        'gr-related-collapse'
+      );
+      assert.isTrue(relatedChanges!.classList.contains('first'));
+    });
+
+    test('first empty second non-empty', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(createRelatedChangesInfo())
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(submittedTogether)
+      );
+      await element.reload();
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(relatedChanges!.classList.contains('first'));
+      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
+        'gr-related-collapse'
+      );
+      assert.isTrue(submittedTogetherSection!.classList.contains('first'));
+    });
+
+    test('first non-empty second empty third non-empty', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(createSubmittedTogetherInfo())
+      );
+      stubRestApi('getChangeCherryPicks').returns(
+        Promise.resolve([createChange()])
+      );
+      await element.reload();
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+        'gr-related-collapse'
+      );
+      assert.isTrue(relatedChanges!.classList.contains('first'));
+      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
+      const cherryPicks = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#cherryPicks'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(cherryPicks!.classList.contains('first'));
+    });
+  });
+
+  test('_changesEqual', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    const change3: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 2 as NumericChangeId,
+    };
+    const change4: RelatedChangeAndCommitInfo = {
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+      _change_number: 1 as NumericChangeId,
+    };
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  suite('get conflicts tests', () => {
     let element: GrRelatedChangesList;
+    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
 
     setup(() => {
       element = basicFixture.instantiate();
+      conflictsStub = stubRestApi('getChangeConflicts').returns(
+        Promise.resolve(undefined)
+      );
     });
 
-    test('_conflicts are an empty array', () => {
+    test('request conflicts if open and mergeable', () => {
       element.patchNum = 7 as PatchSetNum;
       element.change = {
         ...createParsedChange(),
@@ -87,383 +331,300 @@
       };
       element.mergeable = true;
       element.reload();
-      assert.equal(element._conflicts.length, 0);
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
     });
   });
 
-  suite('hidden attribute and update event', () => {
-    const changes: ChangeInfo[] = [
-      {
-        ...createChange(),
-        project: 'foo/bar' as RepoName,
-        change_id: 'Ideadbeef' as ChangeId,
-        status: ChangeStatus.NEW,
+  test('connected revisions', () => {
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      revisions: {
+        e3c6d60783bfdec9ebae7dcfec4662360433449e: createRevision(1),
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': createRevision(2),
+        bf7884d695296ca0c91702ba3e2bc8df0f69a907: createRevision(7),
+        b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3: createRevision(5),
+        d6bcee67570859ccb684873a85cf50b1f0e96fda: createRevision(6),
+        cc960918a7f90388f4a9e05753d0f7b90ad44546: createRevision(3),
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': createRevision(4),
       },
-    ];
-    const relatedChanges: RelatedChangeAndCommitInfo[] = [
+    };
+    let patchNum = 7 as PatchSetNum;
+    let relatedChanges: RelatedChangeAndCommitInfo[] = [
       {
-        ...createCommitInfoWithRequiredCommit(),
-        project: 'foo/bar' as RepoName,
-        change_id: 'Ideadbeef' as ChangeId,
+        ...createRelatedChangeAndCommitInfo(),
         commit: {
-          ...createCommit(),
-          commit: 'deadbeef' as CommitId,
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
           parents: [
             {
-              commit: 'abc123' as CommitId,
-              subject: 'abc123',
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'subject1',
             },
           ],
-          subject: 'do that thing',
         },
-        _change_number: 12345 as NumericChangeId,
-        _revision_number: 1,
-        _current_revision_number: 1,
-        status: ChangeStatus.NEW,
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'subject2',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'subject3',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+          ),
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' as CommitId,
+              subject: 'subject4',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+          ),
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' as CommitId,
+              subject: 'subject5',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+          ),
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' as CommitId,
+              subject: 'subject6',
+            },
+          ],
+        },
       },
     ];
 
-    // obsolete
-    test('clear and empties', () => {
-      element._relatedResponse = {changes: relatedChanges};
-      element._submittedTogether = {
-        changes,
-        non_visible_changes: 0,
-      };
-      element._conflicts = changes;
-      element._cherryPicks = changes;
-      element._sameTopic = changes;
-
-      element.hidden = false;
-      element.clear();
-      assert.isTrue(element.hidden);
-      assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether?.changes.length, 0);
-      assert.equal(element._conflicts.length, 0);
-      assert.equal(element._cherryPicks.length, 0);
-      assert.equal(element._sameTopic?.length, 0);
-    });
-
-    // obsolete
-    test('update fires', () => {
-      const updateHandler = sinon.stub();
-      element.addEventListener('update', updateHandler);
-
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        changes
-      );
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged(
-        {changes: []},
-        {changes, non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 1},
-        [],
-        [],
-        []
-      );
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-    });
-
-    suite('hiding and unhiding', () => {
-      test('related response', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged(
-          {changes: relatedChanges},
-          {changes: [], non_visible_changes: 0},
-          [],
-          [],
-          []
-        );
-        assert.isFalse(element.hidden);
-      });
-
-      test('submitted together', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged(
-          {changes: []},
-          {changes, non_visible_changes: 0},
-          [],
-          [],
-          []
-        );
-        assert.isFalse(element.hidden);
-      });
-
-      test('conflicts', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged(
-          {changes: []},
-          {changes: [], non_visible_changes: 0},
-          changes,
-          [],
-          []
-        );
-        assert.isFalse(element.hidden);
-      });
-
-      test('cherrypicks', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged(
-          {changes: []},
-          {changes: [], non_visible_changes: 0},
-          [],
-          changes,
-          []
-        );
-        assert.isFalse(element.hidden);
-      });
-
-      test('same topic', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged(
-          {changes: []},
-          {changes: [], non_visible_changes: 0},
-          [],
-          [],
-          changes
-        );
-        assert.isFalse(element.hidden);
-      });
-    });
-  });
-
-  // trivial
-  test('_computeChangeURL uses GerritNav', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
-    element._computeChangeURL(
-      123 as NumericChangeId,
-      'abc/def' as RepoName,
-      12 as PatchSetNum
+    let connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
     );
-    assert.isTrue(getUrlStub.called);
-  });
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
 
-  // trivial
-  suite('submitted together changes', () => {
-    const change: ChangeInfo = {
-      ...createChange(),
-      project: 'foo/bar' as RepoName,
-      change_id: 'Ideadbeef' as ChangeId,
-      status: ChangeStatus.NEW,
-    };
-
-    test('_computeSubmittedTogetherClass', () => {
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass(undefined),
-        'hidden'
-      );
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass({
-          changes: [],
-          non_visible_changes: 0,
-        }),
-        'hidden'
-      );
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass({
-          changes: [change],
-          non_visible_changes: 0,
-        }),
-        ''
-      );
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass({
-          changes: [],
-          non_visible_changes: 0,
-        }),
-        'hidden'
-      );
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass({
-          changes: [],
-          non_visible_changes: 1,
-        }),
-        ''
-      );
-      assert.strictEqual(
-        element._computeSubmittedTogetherClass({
-          changes: [],
-          non_visible_changes: 1,
-        }),
-        ''
-      );
-    });
-
-    test('no submitted together changes', () => {
-      flush();
-      assert.include(element.$.submittedTogether.className, 'hidden');
-    });
-
-    test('no non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change], non_visible_changes: 0};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isUndefined(query(element, '.note'));
-    });
-
-    test('no visible submitted together changes', () => {
-      // Technically this should never happen, but worth asserting the logic.
-      element._submittedTogether = {changes: [], non_visible_changes: 1};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.strictEqual(
-        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
-        '(+ 1 non-visible change)'
-      );
-    });
-
-    test('visible and non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change], non_visible_changes: 2};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.strictEqual(
-        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
-        '(+ 2 non-visible changes)'
-      );
-    });
-  });
-
-  // obsolete
-  test('hiding and unhiding', done => {
-    element.change = {...createParsedChange(), labels: {}};
-    let hookEl: HTMLElement;
-    let plugin;
-
-    // No changes, and no plugin. The element is still hidden.
-    element._resultsChanged(
-      {changes: []},
-      {changes: [], non_visible_changes: 0},
-      [],
-      [],
-      []
-    );
-    assert.isTrue(element.hidden);
-    pluginApi.install(
-      p => {
-        plugin = p;
-        plugin
-          .hook('related-changes-section')
-          .getLastAttached()
-          .then(el => (hookEl = el));
+    patchNum = 4 as PatchSetNum;
+    relatedChanges = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
       },
-      '0.1',
-      'http://some/plugins/url2.js'
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b'
+          ),
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+          ),
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'af815dac54318826b7f1fa468acc76349ffc588e'
+          ),
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
     );
-    getPluginLoader().loadPlugins([]);
-    flush(() => {
-      // No changes, and plugin without hidden attribute. So it's visible.
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isFalse(element.hidden);
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
 
-      // No changes, but plugin with true hidden attribute. So it's invisible.
-      hookEl.hidden = true;
+  suite('gr-related-changes-list plugin tests', () => {
+    let element: GrRelatedChangesList;
 
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isTrue(element.hidden);
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+    });
 
-      // No changes, and plugin with false hidden attribute. So it's visible.
-      hookEl.hidden = false;
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        []
-      );
-      assert.isFalse(element.hidden);
+    teardown(() => {
+      resetPlugins();
+    });
 
-      // Hiding triggered by plugin itself
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(
-        new CustomEvent('new-section-loaded', {
-          composed: true,
-          bubbles: true,
-        })
+    test('endpoint params', done => {
+      element.change = {...createParsedChange(), labels: {}};
+      interface RelatedChangesListGrEndpointDecorator
+        extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+      }
+      let hookEl: RelatedChangesListGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('related-changes-section')
+            .getLastAttached()
+            .then(el => (hookEl = el as RelatedChangesListGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url1.js'
       );
-      assert.isTrue(element.hidden);
-
-      // Unhiding triggered by plugin itself
-      hookEl.hidden = false;
-      hookEl.dispatchEvent(
-        new CustomEvent('new-section-loaded', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isFalse(element.hidden);
-
-      // Hiding plugin keeps list visible, if there are changes
-      hookEl.hidden = false;
-      const change = createChange();
-      element._sameTopic = [change];
-      element._resultsChanged(
-        {changes: []},
-        {changes: [], non_visible_changes: 0},
-        [],
-        [],
-        [change]
-      );
-      assert.isFalse(element.hidden);
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(
-        new CustomEvent('new-section-loaded', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isFalse(element.hidden);
-
-      done();
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        done();
+      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 86934c2..adf4f71 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -41,6 +41,7 @@
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends PolymerElement {
@@ -261,32 +262,37 @@
   _handleRemove(e: Event) {
     e.preventDefault();
     const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change) {
-      return;
-    }
+    if (!target.account || !this.change?.reviewers) return;
     const accountID = target.account._account_id || target.account.email;
-    this.disabled = true;
     if (!accountID) return;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then((response: Response | undefined) => {
-        this.disabled = false;
-        if (!response || !response.ok) {
-          return response;
+    const reviewers = this.change.reviewers;
+    let removedAccount: AccountInfo | undefined;
+    let removedType: ReviewerState | undefined;
+    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+      const reviewerStateByType = reviewers[type] || [];
+      reviewers[type] = reviewerStateByType;
+      for (let i = 0; i < reviewerStateByType.length; i++) {
+        if (
+          reviewerStateByType[i]._account_id === accountID ||
+          reviewerStateByType[i].email === accountID
+        ) {
+          removedAccount = reviewerStateByType[i];
+          removedType = type;
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
         }
-        if (!this.change || !this.change.reviewers) return;
-        const reviewers = this.change.reviewers;
-        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-          const reviewerStateByType = reviewers[type] || [];
-          reviewers[type] = reviewerStateByType;
-          for (let i = 0; i < reviewerStateByType.length; i++) {
-            if (
-              reviewerStateByType[i]._account_id === accountID ||
-              reviewerStateByType[i].email === accountID
-            ) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
-          }
+      }
+    }
+    const curChange = this.change;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then(response => {
+        this.disabled = false;
+        if (!this.change?.reviewers || this.change !== curChange) return;
+        if (!response?.ok) {
+          this.push(`change.reviewers.${removedType}`, removedAccount);
+          fireAlert(this, `Cannot remove a ${removedType}`);
+          return response;
         }
         return;
       })
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index 794e9c9..64179f7 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -84,8 +84,9 @@
           type="radio"
           on-click="_handleOnlyDrafts"
           checked="[[_draftsOnly]]"
+          hidden$="[[!loggedIn]]"
         />
-        <label for="draftsRadio">
+        <label for="draftsRadio" hidden$="[[!loggedIn]]">
           Drafts ([[_countDrafts(threads)]])
         </label>
         <input
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 7b47bd7..1fe00ff 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -270,6 +270,17 @@
     });
   });
 
+  test('draft toggle only appears when logged in', () => {
+    element.loggedIn = false;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('#draftsRadio')).display,
+    'none');
+    element.loggedIn = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('#draftsRadio')).display,
+    'none');
+  });
+
   test('show all threads by default', () => {
     assert.equal(dom(element.root)
         .querySelectorAll('gr-comment-thread').length, element.threads.length);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index d61dc36..03d50cc 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -60,7 +60,7 @@
 import {toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly, pluralize} from '../../utils/string-util';
-import {fireRunSelectionReset, isSelected} from './gr-checks-util';
+import {fireRunSelectionReset, isAttemptSelected} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {ConfigInfo, PatchSetNumber} from '../../types/common';
 import {latestPatchNum$} from '../../services/change/change-model';
@@ -465,9 +465,17 @@
   @internalProperty()
   filterRegExp = new RegExp('');
 
+  /** All runs. Shown should only the selected/filtered ones. */
   @property()
   runs: CheckRun[] = [];
 
+  /**
+   * Check names of runs that are selected in the runs panel. When this array
+   * is empty, then no run is selected and all runs should be shown.
+   */
+  @property()
+  selectedRuns: string[] = [];
+
   @property()
   actions: Action[] = [];
 
@@ -483,14 +491,6 @@
   @property()
   latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  /**
-   * How many runs are selected in the runs panel?
-   * If 0, then the `runs` property contains all the runs there are.
-   * If >0, then it only contains the data of certain selected runs.
-   */
-  @property()
-  selectedRunsCount = 0;
-
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
   @property()
   selectedAttempts: Map<string, number | undefined> = new Map<
@@ -759,11 +759,19 @@
     });
   }
 
-  renderFilter() {
-    const runs = this.runs.filter(run =>
-      isSelected(this.selectedAttempts, run)
+  isRunSelected(run: {checkName: string}) {
+    return (
+      this.selectedRuns.length === 0 ||
+      this.selectedRuns.includes(run.checkName)
     );
-    if (this.selectedRunsCount === 0 && allResults(runs).length <= 3) {
+  }
+
+  renderFilter() {
+    const runs = this.runs.filter(
+      run =>
+        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run)
+    );
+    if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
       if (this.filterRegExp.source.length > 0) {
         this.filterRegExp = new RegExp('');
       }
@@ -783,7 +791,7 @@
   }
 
   renderSelectionFilter() {
-    const count = this.selectedRunsCount;
+    const count = this.selectedRuns.length;
     if (count === 0) return;
     return html`
       <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
@@ -806,20 +814,23 @@
 
   renderSection(category: Category | 'SUCCESS') {
     const catString = category.toString().toLowerCase();
-    let runs = this.runs.filter(run => isSelected(this.selectedAttempts, run));
+    let allRuns = this.runs.filter(run =>
+      isAttemptSelected(this.selectedAttempts, run)
+    );
     if (category === 'SUCCESS') {
-      runs = runs.filter(hasCompletedWithoutResults);
+      allRuns = allRuns.filter(hasCompletedWithoutResults);
     } else {
-      runs = runs.filter(r => hasResultsOf(r, category));
+      allRuns = allRuns.filter(r => hasResultsOf(r, category));
     }
-    const all = runs.reduce(
-      (allResults: RunResult[], run) => [
-        ...allResults,
+    const all = allRuns.reduce(
+      (results: RunResult[], run) => [
+        ...results,
         ...this.computeRunResults(category, run),
       ],
       []
     );
-    const filtered = all.filter(
+    const selected = all.filter(result => this.isRunSelected(result));
+    const filtered = selected.filter(
       result =>
         this.filterRegExp.test(result.checkName) ||
         this.filterRegExp.test(result.summary)
@@ -827,7 +838,7 @@
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
     if (!expandedByUser || expanded === undefined) {
-      expanded = all.length > 0;
+      expanded = selected.length > 0;
       this.isSectionExpanded.set(category, expanded);
     }
     const expandedClass = expanded ? 'expanded' : 'collapsed';
@@ -844,22 +855,28 @@
             class="statusIcon ${catString}"
           ></iron-icon>
           <span class="title">${catString}</span>
-          <span class="count">${this.renderCount(all, filtered)}</span>
+          <span class="count"
+            >${this.renderCount(all, selected, filtered)}</span
+          >
         </h3>
-        ${this.renderResults(all, filtered)}
+        ${this.renderResults(all, selected, filtered)}
       </div>
     `;
   }
 
-  renderResults(all: RunResult[], filtered: RunResult[]) {
-    if (all.length === 0 && this.selectedRunsCount > 0) {
+  renderResults(
+    all: RunResult[],
+    selected: RunResult[],
+    filtered: RunResult[]
+  ) {
+    if (all.length === 0) {
+      return html`<div class="noResultsMessage">No results</div>`;
+    }
+    if (selected.length === 0) {
       return html`<div class="noResultsMessage">
         No results for this filtered view
       </div>`;
     }
-    if (all.length === 0) {
-      return html`<div class="noResultsMessage">No results</div>`;
-    }
     if (filtered.length === 0) {
       return html`<div class="noResultsMessage">
         No results match the regular expression
@@ -891,15 +908,14 @@
     `;
   }
 
-  renderCount(all: RunResult[], filtered: RunResult[]) {
-    if (this.selectedRunsCount > 0) {
-      return html`<span class="filtered"> - filtered</span>`;
-    }
+  renderCount(all: RunResult[], selected: RunResult[], filtered: RunResult[]) {
     if (all.length === filtered.length) {
       return html`(${all.length})`;
-    } else {
-      return html`(${filtered.length} of ${all.length})`;
     }
+    if (all.length !== selected.length) {
+      return html`<span class="filtered"> - filtered</span>`;
+    }
+    return html`(${filtered.length} of ${all.length})`;
   }
 
   toggleExpanded(category: Category | 'SUCCESS') {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index beec4c8..acabb22 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -108,11 +108,6 @@
   }
 
   render() {
-    const filteredRuns = this.runs.filter(
-      r =>
-        this.selectedRuns.length === 0 ||
-        this.selectedRuns.includes(r.checkName)
-    );
     return html`
       <div class="container">
         <gr-checks-runs
@@ -127,8 +122,8 @@
         <gr-checks-results
           class="results"
           .tabState="${this.tabState}"
-          .runs="${filteredRuns}"
-          .selectedRunsCount="${this.selectedRuns.length}"
+          .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
           .selectedAttempts="${this.selectedAttempts}"
           @run-selected="${this.handleRunSelected}"
         ></gr-checks-results>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index 9a68e5b..05a87a4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -76,7 +76,7 @@
   );
 }
 
-export function isSelected(
+export function isAttemptSelected(
   selectedAttempts: Map<string, number | undefined>,
   run: CheckRun
 ) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 77fe299..bf4805c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -18,6 +18,7 @@
 import '@polymer/paper-card/paper-card';
 import '@polymer/paper-checkbox/paper-checkbox';
 import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
 import './gr-overview-image';
@@ -36,7 +37,14 @@
 import {classMap} from 'lit-html/directives/class-map';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
 
-import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {
+  createEvent,
+  Dimensions,
+  fitToFrame,
+  FrameConstrainer,
+  Point,
+  Rect,
+} from './util';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
@@ -197,12 +205,27 @@
     }
     #version-switcher {
       display: flex;
+      align-items: center;
       margin: var(--spacing-xl);
     }
     #version-switcher paper-button {
-      flex-basis: 0;
       flex-grow: 1;
       margin: 0;
+      /*
+        The floating action button below overlaps part of the version buttons.
+        This min-width ensures the button text still appears somewhat balanced.
+        */
+      min-width: 7rem;
+    }
+    #version-switcher paper-fab {
+      /* Round button overlaps Base and Revision buttons. */
+      z-index: 10;
+      margin: 0 -12px;
+      /* Styled as an outlined button. */
+      color: var(--primary-button-background-color);
+      border: 1px solid var(--primary-button-background-color);
+      --paper-fab-background: var(--primary-background-color);
+      --paper-fab-keyboard-focus-background: var(--primary-background-color);
     }
     #version-explanation {
       color: var(--deemphasized-text-color);
@@ -253,6 +276,8 @@
         >
           Base
         </paper-button>
+        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.toggleImage}">
+        </paper-fab>
         <paper-button
           class="right"
           ?unelevated=${!this.baseSelected}
@@ -335,13 +360,15 @@
             paper-button.left {
               --paper-button: {
                 border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
+                border-width: 1px;
+                border-style: solid;
+                border-color: var(--primary-button-background-color);
               }
             }
             paper-button.left[outlined] {
               --paper-button: {
                 border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
+                border-width: 1px;
                 border-style: solid;
                 border-color: var(--primary-button-background-color);
               }
@@ -349,13 +376,15 @@
             paper-button.right {
               --paper-button: {
                 border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
+                border-width: 1px;
+                border-style: solid;
+                border-color: var(--primary-button-background-color);
               }
             }
             paper-button.right[outlined] {
               --paper-button: {
                 border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
+                border-width: 1px;
                 border-style: solid;
                 border-color: var(--primary-button-background-color);
               }
@@ -439,11 +468,17 @@
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'base'})
+    );
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'revision'})
+    );
   }
 
   toggleImage() {
@@ -457,16 +492,25 @@
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: 'fit'})
+      );
     }
     if (value > 0) {
       this.scaledSelected = false;
       this.scale = value;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: value})
+      );
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
+    this.dispatchEvent(
+      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
+    );
   }
 
   mousedownMagnifier(event: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
index aae559d..d7b6916 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -25,8 +25,9 @@
   query,
 } from 'lit-element';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {ImageDiffAction} from '../../../api/diff';
 
-import {Dimensions, fitToFrame, Point, Rect} from './util';
+import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -179,9 +180,12 @@
       this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
         this.maybeDragFrame(event)
       );
-      this.overlay.addEventListener('mouseleave', (event: MouseEvent) =>
-        this.releaseFrame(event)
-      );
+      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
+        // Ignore mouseleave events that are due to closeOverlay() calls.
+        if (this.overlay?.style.display !== 'none') {
+          this.releaseFrame(event);
+        }
+      });
       this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
         this.releaseFrame(event)
       );
@@ -254,6 +258,12 @@
 
   releaseFrame(event: MouseEvent) {
     event.preventDefault();
+
+    const detail: ImageDiffAction = {
+      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
+    };
+    this.dispatchEvent(createEvent(detail));
+
     this.dragging = false;
     this.closeOverlay();
     this.grabOffset = {x: 0, y: 0};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
index b42eea9..7036ce4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {ImageDiffAction} from '../../../api/diff';
+
 export interface Point {
   x: number;
   y: number;
@@ -234,3 +236,13 @@
     };
   }
 }
+
+export function createEvent(
+  detail: ImageDiffAction
+): CustomEvent<ImageDiffAction> {
+  return new CustomEvent('image-diff-action', {
+    detail,
+    bubbles: true,
+    composed: true,
+  });
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index dc27a49..2957320 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -140,6 +140,8 @@
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aswap_horiz-->
+      <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 8dcb80e..33e1afef 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -240,6 +240,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-fab",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-icon-button",
     license: SharedLicenses.Polymer2015
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index c943bff..bd0ee89 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -22,6 +22,7 @@
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
     "@polymer/paper-dropdown-menu": "^3.2.0",
+    "@polymer/paper-fab": "^3.0.1",
     "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 0f229df..324a912 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -528,6 +528,7 @@
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
+  accountsInMessage?: AccountInfo[];
   tag?: ReviewInputTag;
   _revision_number?: PatchSetNum;
 }
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 77d3972..5240c8c 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -270,6 +270,17 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.3.1"
 
+"@polymer/paper-fab@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-fab/-/paper-fab-3.0.1.tgz#2636359e7fb70dd5a549ed92ba9b3bdb9ff86bf8"
+  integrity sha512-LO8ckgd72MnAtC1WiPd5CFR27WC/dEuY/lOIQuHYdEjwI62+iiV7Bmr7uoQ9wvvV71qMFdMIOyq/03KklsuAzw==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-icon-button@^3.0.0-pre.26":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/paper-icon-button/-/paper-icon-button-3.0.2.tgz#a1254faadc2c8dd135ce1ae33bcc161a94c31f65"