Merge changes I06f5811b,I488cd376

* changes:
  Surface comparison type and old/new commit IDs in the file diff output
  Convert ComparisonType to AutoValue
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 91fe24b..23720460 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -5189,6 +5189,13 @@
 +
 By default 50.
 
+[[suggest.skipServiceUsers]]suggest.skipServiceUsers::
++
+If link:access-control.html#service_users[service users] should be skipped when
+suggesting reviewers.
++
+By default true.
+
 [[tracing]]
 === Section tracing
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 0ae038a..9d29980 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -376,6 +376,11 @@
 * server
 * ssh
 
+Bazel itself supports a multitude of ways to
+link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+for fine-grained test selection that can be combined with many of the examples
+above.
+
 [[elasticsearch]]
 === Elasticsearch
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 48e729f..7ef9473 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,6 +247,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index b7cdf8a..5f0fb65 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3204,6 +3204,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 9764c8a..a8d9b3d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1839,6 +1839,8 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
+|`instance_id`       |optional|
+link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
 |=================================
 
 [[index-config-info]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index e02dc21..52c282e 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -190,6 +190,13 @@
 +
 Changes occurring in projects starting with 'PREFIX'.
 
+[[parentof]]
+parentof:'ID'::
+Changes which are parent to the change specified by 'ID'. Change 'ID' can be
+specified as a legacy numerical 'ID' such as 15183, or a Change-Id that can be
+picked from the commit message. This operator will return immediate parents
+and will not return grand parents or higher level ancestors of the given change.
+
 [[parentproject]]
 parentproject:'PROJECT'::
 +
diff --git a/WORKSPACE b/WORKSPACE
index ccbc63d..c24d4f9 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -842,55 +842,55 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.35.v20201120"
+JETTY_VERS = "9.4.36.v20210114"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "3e61bcb471e1bfc545ce866cbbe33c3aedeec9b1",
+    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "80dc2f422789c78315de76d289b7a5b36c3232d5",
+    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "513502352fd689d4730b2935421b990ada8cc818",
+    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "38812031940a466d626ab5d9bbbd9d5d39e9f735",
+    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "45d35131a35a1e76991682174421e8cdf765fb9f",
+    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "eb9460700b99b71ecd82a53697f5ff99f69b9e1c",
+    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "ef61b83f9715c3b5355b633d9f01d2834f908ece",
+    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
 )
 
 maven_jar(
     name = "jetty-util-ajax",
     artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "ebbb43912c6423bedb3458e44aee28eeb4d66f27",
-    src_sha1 = "b3acea974a17493afb125a9dfbe783870ce1d2f9",
+    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
+    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 0b755b7..2a94bc8 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -37,6 +35,8 @@
  */
 @AutoValue
 public abstract class CachedProjectConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public abstract Project getProject();
 
   public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
@@ -126,34 +126,10 @@
 
   public abstract ImmutableMap<String, String> getPluginConfigs();
 
-  /**
-   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
-   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
-   *
-   * @param fileName the name of the file. Must end in {@code .config}.
-   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
-   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
-   *     surface validation errors in case of a parsing issue.
-   */
-  public Optional<Config> getProjectLevelConfig(String fileName) {
-    checkState(fileName.endsWith(".config"), "file name must end in .config");
-    if (getProjectLevelConfigs().containsKey(fileName)) {
-      Config config = new Config();
-      try {
-        config.fromText(getProjectLevelConfigs().get(fileName));
-      } catch (ConfigInvalidException e) {
-        // This is OK to propagate as IllegalStateException because it's a programmer error.
-        // The config was converted to a String using Config#toText. So #fromText must not
-        // throw a ConfigInvalidException
-        throw new IllegalStateException("invalid config for " + fileName, e);
-      }
-      return Optional.of(config);
-    }
-    return Optional.empty();
-  }
-
   public abstract ImmutableMap<String, String> getProjectLevelConfigs();
 
+  public abstract ImmutableMap<String, ImmutableConfig> getParsedProjectLevelConfigs();
+
   public static Builder builder() {
     return new AutoValue_CachedProjectConfig.Builder();
   }
@@ -235,8 +211,15 @@
 
     abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
 
+    abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
+
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
+      try {
+        parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
+      } catch (ConfigInvalidException e) {
+        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+      }
       return this;
     }
 
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
new file mode 100644
index 0000000..a5efc14
--- /dev/null
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -0,0 +1,91 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Immutable parsed representation of a {@link org.eclipse.jgit.lib.Config} that can be cached.
+ * Supports only a limited set of operations.
+ */
+public class ImmutableConfig {
+  public static final ImmutableConfig EMPTY = new ImmutableConfig("", new Config());
+
+  private final String stringCfg;
+  private final Config cfg;
+
+  private ImmutableConfig(String stringCfg, Config cfg) {
+    this.stringCfg = stringCfg;
+    this.cfg = cfg;
+  }
+
+  public static ImmutableConfig parse(String stringCfg) throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(stringCfg);
+    return new ImmutableConfig(stringCfg, cfg);
+  }
+
+  /** Returns a mutable copy of this config. */
+  public Config mutableCopy() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(this.cfg.toText());
+    } catch (ConfigInvalidException e) {
+      // Can't happen as we used JGit to format that config.
+      throw new IllegalStateException(e);
+    }
+    return cfg;
+  }
+
+  /** @see Config#getSections() */
+  public Set<String> getSections() {
+    return cfg.getSections();
+  }
+
+  /** @see Config#getNames(String) */
+  public Set<String> getNames(String section) {
+    return cfg.getNames(section);
+  }
+
+  /** @see Config#getNames(String, String) */
+  public Set<String> getNames(String section, String subsection) {
+    return cfg.getNames(section, subsection);
+  }
+
+  /** @see Config#getStringList(String, String, String) */
+  public String[] getStringList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  /** @see Config#getSubsections(String) */
+  public Set<String> getSubsections(String section) {
+    return cfg.getSubsections(section);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ImmutableConfig)) {
+      return false;
+    }
+    return ((ImmutableConfig) o).stringCfg.equals(stringCfg);
+  }
+
+  @Override
+  public int hashCode() {
+    return stringCfg.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 6ab80b2..2144ed5 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.Objects;
 
 /**
  * Representation of an action in the REST API.
@@ -55,4 +56,23 @@
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ActionInfo) {
+      ActionInfo actionInfo = (ActionInfo) o;
+      return Objects.equals(method, actionInfo.method)
+          && Objects.equals(label, actionInfo.label)
+          && Objects.equals(title, actionInfo.title)
+          && Objects.equals(enabled, actionInfo.enabled);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(method, label, title, enabled);
+  }
+
+  protected ActionInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index f95ddff..bf72e83 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Representation of an approval in the REST API.
@@ -71,4 +72,23 @@
     this.date = date;
     this.tag = tag;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ApprovalInfo) {
+      ApprovalInfo approvalInfo = (ApprovalInfo) o;
+      return super.equals(o)
+          && Objects.equals(tag, approvalInfo.tag)
+          && Objects.equals(value, approvalInfo.value)
+          && Objects.equals(date, approvalInfo.date)
+          && Objects.equals(postSubmit, approvalInfo.postSubmit)
+          && Objects.equals(permittedVotingRange, approvalInfo.permittedVotingRange);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index f29d32b..ba865fb 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Represents a single user included in the attention set. Used in the API. See {@link
@@ -36,4 +37,22 @@
     this.lastUpdate = lastUpdate;
     this.reason = reason;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AttentionSetInfo) {
+      AttentionSetInfo attentionSetInfo = (AttentionSetInfo) o;
+      return Objects.equals(account, attentionSetInfo.account)
+          && Objects.equals(lastUpdate, attentionSetInfo.lastUpdate)
+          && Objects.equals(reason, attentionSetInfo.reason);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(account, lastUpdate, reason);
+  }
+
+  protected AttentionSetInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 75665a8..b620ac2 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 /**
  * Representation of an avatar in the REST API.
  *
@@ -38,4 +40,20 @@
 
   /** The width of the avatar image in pixels. */
   public Integer width;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AvatarInfo) {
+      AvatarInfo avatarInfo = (AvatarInfo) o;
+      return Objects.equals(url, avatarInfo.url)
+          && Objects.equals(height, avatarInfo.height)
+          && Objects.equals(width, avatarInfo.width);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, height, width);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/FetchInfo.java b/java/com/google/gerrit/extensions/common/FetchInfo.java
index eda84b1..4b1e941 100644
--- a/java/com/google/gerrit/extensions/common/FetchInfo.java
+++ b/java/com/google/gerrit/extensions/common/FetchInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class FetchInfo {
   public String url;
@@ -25,4 +26,22 @@
     this.url = url;
     this.ref = ref;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FetchInfo) {
+      FetchInfo fetchInfo = (FetchInfo) o;
+      return Objects.equals(url, fetchInfo.url)
+          && Objects.equals(ref, fetchInfo.ref)
+          && Objects.equals(commands, fetchInfo.commands);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, ref, commands);
+  }
+
+  protected FetchInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 2ae6703..3265a00 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -23,4 +23,5 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String primaryWeblinkName;
+  public String instanceId;
 }
diff --git a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
index 7a5c15b..d656f22 100644
--- a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
+++ b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class GpgKeyInfo {
   /**
@@ -43,4 +44,22 @@
 
   public Status status;
   public List<String> problems;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof GpgKeyInfo) {
+      GpgKeyInfo gpgKeyInfo = (GpgKeyInfo) o;
+      return Objects.equals(id, gpgKeyInfo.id)
+          && Objects.equals(fingerprint, gpgKeyInfo.fingerprint)
+          && Objects.equals(userIds, gpgKeyInfo.userIds)
+          && Objects.equals(status, gpgKeyInfo.status)
+          && Objects.equals(problems, gpgKeyInfo.problems);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, fingerprint, userIds, status, problems);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 76dd93d..44bcdaf 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -16,6 +16,7 @@
 
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 public class LabelInfo {
   public AccountInfo approved;
@@ -30,4 +31,37 @@
   public Short defaultValue;
   public Boolean optional;
   public Boolean blocking;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof LabelInfo) {
+      LabelInfo labelInfo = (LabelInfo) o;
+      return Objects.equals(approved, labelInfo.approved)
+          && Objects.equals(rejected, labelInfo.rejected)
+          && Objects.equals(recommended, labelInfo.recommended)
+          && Objects.equals(disliked, labelInfo.disliked)
+          && Objects.equals(all, labelInfo.all)
+          && Objects.equals(values, labelInfo.values)
+          && Objects.equals(value, labelInfo.value)
+          && Objects.equals(defaultValue, labelInfo.defaultValue)
+          && Objects.equals(optional, labelInfo.optional)
+          && Objects.equals(blocking, labelInfo.blocking);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        approved,
+        rejected,
+        recommended,
+        disliked,
+        all,
+        values,
+        value,
+        defaultValue,
+        optional,
+        blocking);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index 69bfa2c..e2b1c36 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PluginDefinedInfo {
   public String name;
   public String message;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PluginDefinedInfo) {
+      PluginDefinedInfo pluginDefinedInfo = (PluginDefinedInfo) o;
+      return Objects.equals(name, pluginDefinedInfo.name)
+          && Objects.equals(message, pluginDefinedInfo.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, message);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
index 9eed808..199dbd1 100644
--- a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
+++ b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PushCertificateInfo {
   public String certificate;
   public GpgKeyInfo key;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PushCertificateInfo) {
+      PushCertificateInfo pushCertificateInfo = (PushCertificateInfo) o;
+      return Objects.equals(certificate, pushCertificateInfo.certificate)
+          && Objects.equals(key, pushCertificateInfo.key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(certificate, key);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index eccdc64..37e1ceb 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,10 +16,28 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public class ReviewerUpdateInfo {
   public Timestamp updated;
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ReviewerUpdateInfo) {
+      ReviewerUpdateInfo reviewerUpdateInfo = (ReviewerUpdateInfo) o;
+      return Objects.equals(updated, reviewerUpdateInfo.updated)
+          && Objects.equals(updatedBy, reviewerUpdateInfo.updatedBy)
+          && Objects.equals(reviewer, reviewerUpdateInfo.reviewer)
+          && Objects.equals(state, reviewerUpdateInfo.state);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(updated, updatedBy, reviewer, state);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f262901..ea61f31 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.util.Map;
+import java.util.Objects;
 
 public class RevisionInfo {
   // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
@@ -34,4 +35,43 @@
   public String commitWithFooters;
   public PushCertificateInfo pushCertificate;
   public String description;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof RevisionInfo) {
+      RevisionInfo revisionInfo = (RevisionInfo) o;
+      return isCurrent == revisionInfo.isCurrent
+          && Objects.equals(kind, revisionInfo.kind)
+          && _number == revisionInfo._number
+          && Objects.equals(created, revisionInfo.created)
+          && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(ref, revisionInfo.ref)
+          && Objects.equals(fetch, revisionInfo.fetch)
+          && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(files, revisionInfo.files)
+          && Objects.equals(actions, revisionInfo.actions)
+          && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
+          && Objects.equals(pushCertificate, revisionInfo.pushCertificate)
+          && Objects.equals(description, revisionInfo.description);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        isCurrent,
+        kind,
+        _number,
+        created,
+        uploader,
+        ref,
+        fetch,
+        commit,
+        files,
+        actions,
+        commitWithFooters,
+        pushCertificate,
+        description);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
index 3483de5..a13e645 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -18,9 +18,9 @@
 import java.util.Objects;
 
 public class SubmitRequirementInfo {
-  public final String status;
-  public final String fallbackText;
-  public final String type;
+  public String status;
+  public String fallbackText;
+  public String type;
 
   public SubmitRequirementInfo(String status, String fallbackText, String type) {
     this.status = status;
@@ -55,4 +55,6 @@
         .add("type", type)
         .toString();
   }
+
+  protected SubmitRequirementInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
index 0c5ed68..3d35e08 100644
--- a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class TrackingIdInfo {
   public String system;
   public String id;
@@ -22,4 +24,20 @@
     this.system = system;
     this.id = id;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof TrackingIdInfo) {
+      TrackingIdInfo trackingIdInfo = (TrackingIdInfo) o;
+      return Objects.equals(system, trackingIdInfo.system) && Objects.equals(id, trackingIdInfo.id);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(system, id);
+  }
+
+  protected TrackingIdInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
index 5c35a49..2f7e9e4 100644
--- a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class VotingRangeInfo {
   public int min;
   public int max;
@@ -22,4 +24,18 @@
     this.min = min;
     this.max = max;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof VotingRangeInfo) {
+      VotingRangeInfo votingRangeInfo = (VotingRangeInfo) o;
+      return min == votingRangeInfo.min && max == votingRangeInfo.max;
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(min, max);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index 84fd970..ba12be0 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -64,4 +64,6 @@
         + target
         + "}";
   }
+
+  protected WebLinkInfo() {}
 }
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 572ae7a..d77427a 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -109,10 +110,10 @@
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
-      PatchList patchList) {
+      LabelType type,
+      @Nullable PatchList patchList) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
 
     if (type == null) {
       logger.atFine().log(
@@ -367,12 +368,18 @@
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
-    PatchList patchList = getPatchList(project, ps, priorPatchSet);
+    PatchList patchList = null;
+    LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      if (!canCopy(project, psa, ps.id(), kind, patchList)) {
+      LabelType type = labelTypes.byLabel(psa.labelId());
+      // Only compute patchList if there is a relevant label, since this is expensive.
+      if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        patchList = getPatchList(project, ps, priorPatchSet);
+      }
+      if (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index a3136d4a..761b57d 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -47,20 +46,17 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      AccountLoader.Factory accountLoaderFactory) {
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -123,7 +119,7 @@
     if (ps != null) {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
 
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+      for (SubmitRecord rec : cd.submitRecords(SubmitRuleOptions.defaults())) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d507531..6e640f3 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -34,6 +35,8 @@
  * issues. Note that autogenerated change messages are not subject to validation.
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
+  public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
+
   private final int maxCumulativeSize;
   private final ChangeNotes.Factory notesFactory;
 
@@ -41,7 +44,9 @@
   CommentCumulativeSizeValidator(
       @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
     this.notesFactory = notesFactory;
-    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change", "cumulativeCommentSizeLimit", DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   @Override
@@ -55,7 +60,13 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+            + notes.getChangeMessages().stream()
+                // Auto-generated change messages are not counted for the limit. This method is not
+                // called when those change messages are created, but we should also skip them when
+                // counting the size for unrelated messages.
+                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
+                .mapToInt(cm -> cm.getMessage().length())
+                .sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index fe915c5..ac37411 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -109,6 +109,7 @@
               ActionType.GIT_UPDATE,
               "createAutoMerge",
               () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
+          .defaultTimeoutMultiplier(2)
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 9f58aaf..18d532b 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -30,7 +30,9 @@
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.prettify.common.SparseFileContent.Accessor;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -41,6 +43,7 @@
 import java.util.List;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * This class is used on submit to compute the diff between the latest approved patch-set, and the
@@ -58,15 +61,22 @@
   private final ProjectCache projectCache;
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final PatchListCache patchListCache;
+  private final int maxCumulativeSize;
 
   @Inject
   SubmitWithStickyApprovalDiff(
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
-      PatchListCache patchListCache) {
+      PatchListCache patchListCache,
+      @GerritServerConfig Config serverConfig) {
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.patchListCache = patchListCache;
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change",
+            "cumulativeCommentSizeLimit",
+            CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   public String apply(ChangeNotes notes, CurrentUser currentUser)
@@ -88,8 +98,10 @@
       // If the latest approved patchset is the current patchset, no need to return anything.
       return "";
     }
-    String diff =
-        String.format("\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
     PatchList patchList =
         getPatchList(
             notes.getProjectName(),
@@ -103,19 +115,29 @@
             .collect(Collectors.toList());
 
     if (patchListEntryList.isEmpty()) {
-      diff +=
-          "No files were changed between the latest approved patch-set and the submitted one.\n";
-      return diff;
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
     }
 
-    diff += "The change was submitted with unreviewed changes in the following files:\n\n";
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
 
     for (PatchListEntry patchListEntry : patchListEntryList) {
-      diff +=
+      diff.append(
           getDiffForFile(
-              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser);
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
     }
-    return diff;
+    if (diff.length() > maxCumulativeSize) {
+      // The diff length is not counted as part of the limit (for technical reasons, since we'd
+      // have to call CommentCumulativeSizeValidator), but it's best not to post an extra large
+      // change message here.
+      return String.format(
+          "\n\n%d is the latest approved patch-set.\nThe change was submitted "
+              + "with many unreviewed changes (the diff is too large to show). Please review the "
+              + "diff.",
+          latestApprovedPatchsetId.get());
+    }
+    return diff.toString();
   }
 
   private String getDiffForFile(
@@ -126,12 +148,13 @@
       CurrentUser currentUser)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    String diff =
-        String.format(
-            "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
-            patchListEntry.getNewName(),
-            patchListEntry.getInsertions(),
-            patchListEntry.getDeletions());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     PatchScriptFactory patchScriptFactory =
         patchScriptFactoryFactory.create(
@@ -145,66 +168,66 @@
     try {
       patchScript = patchScriptFactory.call();
     } catch (LargeObjectException exception) {
-      diff += "The file content is too large for showing the full diff. \n\n";
-      return diff;
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
     }
     if (patchScript.getChangeType() == ChangeType.RENAMED) {
-      diff +=
+      diff.append(
           String.format(
               "The file %s was renamed to %s\n",
-              patchListEntry.getOldName(), patchListEntry.getNewName());
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
     }
     SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
     SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
     boolean editsExist = false;
     if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
-      diff += "```\n";
+      diff.append("```\n");
       editsExist = true;
     }
     for (Edit edit : patchScript.getEdits()) {
-      diff += getDiffForEdit(fileA, fileB, edit);
+      diff.append(getDiffForEdit(fileA, fileB, edit));
     }
     if (editsExist) {
-      diff += "```\n";
+      diff.append("```\n");
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     Edit.Type type = edit.getType();
     switch (type) {
       case INSERT:
-        diff += String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case DELETE:
-        diff += String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += "\n";
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
         break;
       case REPLACE:
-        diff +=
+        diff.append(
             String.format(
                 "@@ -%d:%d, +%d:%d @@\n",
-                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case EMPTY:
         // do nothing since there is no change here.
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     for (int i = begin; i < end; i++) {
-      diff += String.format("%c  %s\n", modificationType, file.get(i));
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
     }
-    return diff;
+    return diff.toString();
   }
 
   private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4825233..555cf4c 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -16,14 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.ImmutableConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -73,19 +74,17 @@
 
   private final String fileName;
   private final ProjectState project;
-  private Config cfg;
+  private final ImmutableConfig immutableConfig;
 
-  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
+  public ProjectLevelConfig(
+      String fileName, ProjectState project, @Nullable ImmutableConfig immutableConfig) {
     this.fileName = fileName;
     this.project = project;
-    this.cfg = cfg;
+    this.immutableConfig = immutableConfig == null ? ImmutableConfig.EMPTY : immutableConfig;
   }
 
   public Config get() {
-    if (cfg == null) {
-      cfg = new Config();
-    }
-    return cfg;
+    return immutableConfig.mutableCopy();
   }
 
   public Config getWithInheritance() {
@@ -105,58 +104,54 @@
    * @return a combined config.
    */
   public Config getWithInheritance(boolean merge) {
-    Config cfgWithInheritance = new Config();
-    try {
-      cfgWithInheritance.fromText(get().toText());
-    } catch (ConfigInvalidException e) {
-      // cannot happen
-    }
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    if (parent != null) {
-      Config parentCfg = parent.getConfig(fileName).getWithInheritance();
-      for (String section : parentCfg.getSections()) {
-        Set<String> allNames = get().getNames(section);
-        for (String name : parentCfg.getNames(section)) {
-          String[] parentValues = parentCfg.getStringList(section, null, name);
-          if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
-          } else if (merge) {
-            cfgWithInheritance.setStringList(
-                section,
-                null,
-                name,
-                Stream.concat(
-                        Arrays.stream(cfg.getStringList(section, null, name)),
-                        Arrays.stream(parentValues))
-                    .sorted()
-                    .distinct()
-                    .collect(toList()));
-          }
-        }
+    Config cfg = new Config();
+    // Traverse from All-Projects down to the current project
+    StreamSupport.stream(project.treeInOrder().spliterator(), false)
+        .forEach(
+            parent -> {
+              ImmutableConfig levelCfg = parent.getConfig(fileName).immutableConfig;
+              for (String section : levelCfg.getSections()) {
+                Set<String> allNames = cfg.getNames(section);
+                for (String name : levelCfg.getNames(section)) {
+                  String[] levelValues = levelCfg.getStringList(section, null, name);
+                  if (allNames.contains(name) && merge) {
+                    cfg.setStringList(
+                        section,
+                        null,
+                        name,
+                        Stream.concat(
+                                Arrays.stream(cfg.getStringList(section, null, name)),
+                                Arrays.stream(levelValues))
+                            .sorted()
+                            .distinct()
+                            .collect(toList()));
+                  } else {
+                    cfg.setStringList(section, null, name, Arrays.asList(levelValues));
+                  }
+                }
 
-        for (String subsection : parentCfg.getSubsections(section)) {
-          allNames = get().getNames(section, subsection);
-          for (String name : parentCfg.getNames(section, subsection)) {
-            String[] parentValues = parentCfg.getStringList(section, subsection, name);
-            if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(
-                  section, subsection, name, Arrays.asList(parentValues));
-            } else if (merge) {
-              cfgWithInheritance.setStringList(
-                  section,
-                  subsection,
-                  name,
-                  Streams.concat(
-                          Arrays.stream(cfg.getStringList(section, subsection, name)),
-                          Arrays.stream(parentValues))
-                      .sorted()
-                      .distinct()
-                      .collect(toList()));
-            }
-          }
-        }
-      }
-    }
-    return cfgWithInheritance;
+                for (String subsection : levelCfg.getSubsections(section)) {
+                  allNames = cfg.getNames(section, subsection);
+                  for (String name : levelCfg.getNames(section, subsection)) {
+                    String[] levelValues = levelCfg.getStringList(section, subsection, name);
+                    if (allNames.contains(name) && merge) {
+                      cfg.setStringList(
+                          section,
+                          subsection,
+                          name,
+                          Streams.concat(
+                                  Arrays.stream(cfg.getStringList(section, subsection, name)),
+                                  Arrays.stream(levelValues))
+                              .sorted()
+                              .distinct()
+                              .collect(toList()));
+                    } else {
+                      cfg.setStringList(section, subsection, name, Arrays.asList(levelValues));
+                    }
+                  }
+                }
+              }
+            });
+    return cfg;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 8c024ef..249eb35 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
 import static java.util.Comparator.comparing;
@@ -176,8 +177,9 @@
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
-    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
+    checkState(fileName.endsWith(".config"), "file name must end in .config. is: " + fileName);
+    return new ProjectLevelConfig(
+        fileName, this, cachedConfig.getParsedProjectLevelConfigs().get(fileName));
   }
 
   public long getMaxObjectSizeLimit() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 05ecc61..bf56000 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -109,6 +109,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * ChangeData provides lazily loaded interface to change metadata loaded from NoteDb. It can be
+ * constructed by loading from NoteDb, or calling setters. The latter happens when ChangeData is
+ * retrieved through the change index. This happens for Applications that are performance sensitive
+ * (eg. dashboard loads, git protocol negotiation) but can tolerate staleness. In that case, setting
+ * lazyLoad=false disables loading from NoteDb, so we don't accidentally enable a slow path.
+ */
 public class ChangeData {
   public static List<Change> asChanges(List<ChangeData> changeDatas) {
     List<Change> result = new ArrayList<>(changeDatas.size());
@@ -600,6 +607,7 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
+      merge = parentCount > 0;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -864,36 +872,25 @@
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = getCachedSubmitRecord(options);
+    // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
+    // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
+    // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
+    // evaluation.
+    List<SubmitRecord> records = submitRecords.get(options);
     if (records == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
+      if (!change().isClosed() && submitRecords.size() == 1) {
+        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
+        submitRecords.put(options.toBuilder().allowClosed(!options.allowClosed()).build(), records);
+      }
     }
     return records;
   }
 
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return getCachedSubmitRecord(options);
-  }
-
-  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records != null) {
-      return records;
-    }
-
-    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
-      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
-      return submitRecords.get(openSubmitRuleOptions);
-    }
-
-    return null;
-  }
-
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
   }
@@ -961,7 +958,7 @@
         return null;
       }
     }
-    return parentCount > 1;
+    return merge;
   }
 
   public Set<Account.Id> editsByUser() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 68a90d2..4e3edcd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -173,6 +173,7 @@
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTOF = "parentof";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
@@ -735,6 +736,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> parentof(String value) throws QueryParseException {
+    List<ChangeData> changes = parseChangeData(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (ChangeData c : changes) {
+      or.add(new ParentOfPredicate(value, c, args.repoManager));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
   }
@@ -1560,14 +1571,18 @@
   }
 
   private List<Change> parseChange(String value) throws QueryParseException {
+    return asChanges(parseChangeData(value));
+  }
+
+  private List<ChangeData> parseChangeData(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
+      return args.queryProvider.get().byLegacyChangeId(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
new file mode 100644
index 0000000..e48d586
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
@@ -0,0 +1,62 @@
+// 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.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ParentOfPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected final Set<RevCommit> parents;
+
+  public ParentOfPredicate(String value, ChangeData change, GitRepositoryManager repoManager) {
+    super(ChangeQueryBuilder.FIELD_PARENTOF, value);
+    this.parents = getParents(change, repoManager);
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return changeData.patchSets().stream().anyMatch(ps -> parents.contains(ps.commitId()));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  protected Set<RevCommit> getParents(ChangeData change, GitRepositoryManager repoManager) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = repoManager.openRepository(change.project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ps.commitId());
+      return Sets.newHashSet(c.getParents());
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index a360510..73b38b2 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -1228,9 +1228,10 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      ChangeData cd = changeDataFactory.create(ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
+      return approvalsUtil
+          .getReviewers(ctx.getNotes())
+          .byState(REVIEWER)
+          .contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
@@ -1272,28 +1273,27 @@
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null) {
-          // Check if the label exists in the request input (the user voted again). If the user
-          // hadn't voted again, there is no need to re-apply the vote.
-          if (inLabels.keySet().contains(c.label())) {
-            PatchSetApproval.Builder b =
-                c.toBuilder()
-                    .value(ent.getValue())
-                    .granted(ctx.getWhen())
-                    .tag(Optional.ofNullable(in.tag));
-            ctx.getUser().updateRealAccountId(b::realAccountId);
-            c = b.build();
-            ups.add(c);
-            addLabelDelta(normName, c.value());
-            oldApprovals.put(normName, previous.get(normName));
-            approvals.put(normName, c.value());
-            update.putApproval(normName, ent.getValue());
-          } else {
-            current.put(normName, c);
-            oldApprovals.put(normName, null);
-            approvals.put(normName, c.value());
-          }
-        } else {
+          // Only allow voting again if the vote is copied over from a past patch-set, or the
+          // values are different.
+        } else if (c != null
+            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
+          PatchSetApproval.Builder b =
+              c.toBuilder()
+                  .value(ent.getValue())
+                  .granted(ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag));
+          ctx.getUser().updateRealAccountId(b::realAccountId);
+          c = b.build();
+          ups.add(c);
+          addLabelDelta(normName, c.value());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.value());
+          update.putApproval(normName, ent.getValue());
+        } else if (c != null && c.value() == ent.getValue()) {
+          current.put(normName, c);
+          oldApprovals.put(normName, null);
+          approvals.put(normName, c.value());
+        } else if (c == null) {
           c =
               ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
                   .tag(Optional.ofNullable(in.tag))
@@ -1319,6 +1319,17 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
+    /**
+     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
+     * according to change notes (which means it was computed in {@link
+     * com.google.gerrit.server.ApprovalInference})
+     */
+    private boolean isApprovalCopiedOver(
+        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
+          .anyMatch(p -> p.equals(patchSetApproval));
+    }
+
     private void validatePostSubmitLabels(
         ChangeContext ctx,
         LabelTypes labelTypes,
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 2a55e41..38be27e 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -130,6 +131,7 @@
   private final IndexConfig indexConfig;
   private final AccountControl.Factory accountControlFactory;
   private final Provider<CurrentUser> self;
+  private final ServiceUserClassifier serviceUserClassifier;
 
   @Inject
   ReviewersUtil(
@@ -143,7 +145,8 @@
       AccountIndexCollection accountIndexes,
       IndexConfig indexConfig,
       AccountControl.Factory accountControlFactory,
-      Provider<CurrentUser> self) {
+      Provider<CurrentUser> self,
+      ServiceUserClassifier serviceUserClassifier) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.accountIndexRewriter = accountIndexRewriter;
@@ -155,6 +158,7 @@
     this.indexConfig = indexConfig;
     this.accountControlFactory = accountControlFactory;
     this.self = self;
+    this.serviceUserClassifier = serviceUserClassifier;
   }
 
   public interface VisibilityControl {
@@ -200,13 +204,17 @@
             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);
 
-    // Filter accounts by visibility and enforce limit
+    // Filter accounts by visibility, skip service users and enforce limit
     List<Account.Id> filteredRecommendations = new ArrayList<>();
     try (Timer0.Context ctx = metrics.filterVisibility.start()) {
       for (Account.Id reviewer : sortedRecommendations) {
         if (filteredRecommendations.size() >= limit) {
           break;
         }
+        if (suggestReviewers.isSkipServiceUsers()
+            && serviceUserClassifier.isServiceUser(reviewer)) {
+          continue;
+        }
         // Check if change is visible to reviewer and if the current user can see reviewer
         if (visibilityControl.isVisibleTo(reviewer)
             && accountControlFactory.get().canSee(reviewer)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index e071c89..71ff493 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -31,6 +31,8 @@
 
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
+  private static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
+
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
@@ -39,6 +41,7 @@
   protected int limit;
   protected String query;
   protected final int maxSuggestedReviewers;
+  protected boolean skipServiceUsers;
 
   @Option(
       name = "--limit",
@@ -78,6 +81,10 @@
     return maxAllowedWithoutConfirmation;
   }
 
+  public boolean isSkipServiceUsers() {
+    return skipServiceUsers;
+  }
+
   @Inject
   public SuggestReviewers(
       AccountVisibility av, @GerritServerConfig Config cfg, ReviewersUtil reviewersUtil) {
@@ -100,6 +107,9 @@
             ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
+
+    this.skipServiceUsers =
+        cfg.getBoolean("suggest", "skipServiceUsers", DEFAULT_SKIP_SERVICE_USERS);
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 5459ede..0a5692e 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -304,6 +304,7 @@
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
+    info.instanceId = config.getString("gerrit", null, "instanceId");
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 871d8d2..f486650 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -189,7 +189,7 @@
       // date by this point.
       ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
       return requireNonNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          cd.submitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -549,8 +549,8 @@
             .listener(retryTracker)
             // Up to the entire submit operation is retried, including possibly many projects.
             // Multiply the timeout by the number of projects we're actually attempting to
-            // submit.
-            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size())
+            // submit. Times 2 to retry more persistently, to increase success rate.
+            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
             // By default, we only retry lock failures. Here it's better to also retry unexpected
             // runtime exceptions.
             .retryOn(t -> t instanceof RuntimeException)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 0b2e0f7..c8b1715 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -63,7 +63,6 @@
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -597,7 +596,7 @@
       input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
       gApi.changes().id(r.getChangeId()).current().review(input);
       testOnPostReview.assertApproval(
-          LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 2);
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
 
       // Delete the vote.
       input = new ReviewInput().label(LabelId.CODE_REVIEW, 0);
@@ -627,21 +626,19 @@
     assertThat(r.getChange().approvals().values()).hasSize(1);
     List<ChangeMessageInfo> changeMessages = gApi.changes().id(r.getChangeId()).messages();
 
-    // The two latest change messages are both about Code-Review+2
+    // Only the last change message is about Code-Review+2
     assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2");
     changeMessages.remove(changeMessages.size() - 1);
-    assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2");
+    assertThat(Iterables.getLast(changeMessages).message)
+        .isNotEqualTo("Patch Set 1: Code-Review+2");
 
-    // The two latest emails are about Code-Review +2.
-    List<FakeEmailSender.Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(2);
-    for (FakeEmailSender.Message message : messages) {
-      assertThat(message.body()).contains("Patch Set 1: Code-Review+2");
-    }
+    // Only one email is about Code-Review +2 was sent.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Patch Set 1: Code-Review+2");
   }
 
   @Test
-  public void votingTheSameVoteSecondTimeExtendsOnPostReview() throws Exception {
+  public void votingTheSameVoteSecondTimeExtendsOnPostReviewWithOldNullValue() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote.
@@ -656,12 +653,12 @@
       gApi.changes().id(r.getChangeId()).current().review(input);
 
       testOnPostReview.assertApproval(
-          LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 2);
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
     }
   }
 
   @Test
-  public void votingTheSameVoteSecondTimeFiresOnCommentAdded() throws Exception {
+  public void votingTheSameVoteSecondTimeDoesNotFireOnCommentAdded() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote.
@@ -675,8 +672,8 @@
       input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
       gApi.changes().id(r.getChangeId()).current().review(input);
 
-      assertThat(testListener.lastCommentAddedEvent.getComment())
-          .isEqualTo("Patch Set 1: Code-Review+2");
+      // Event not fired.
+      assertThat(testListener.lastCommentAddedEvent).isNull();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index eba2634..5ca7310 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -21,8 +21,10 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
@@ -30,6 +32,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
@@ -144,6 +147,56 @@
   }
 
   @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    String content = new String(new char[800]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post a submit diff that is almost the cumulativeCommentSizeLimit
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .doesNotContain("many unreviewed changes");
+
+    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
+    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
+    // 800 is not counted).
+    String message = new String(new char[400]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void postSubmitDiffCannotBeTooBig() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    String content = new String(new char[1100]).replace("\0", "a");
+
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted "
+                + "with many unreviewed changes (the diff is too large to show). Please review the "
+                + "diff.");
+  }
+
+  @Test
   public void diffChangeMessageOnSubmitWithStickyVote_addedFile() throws Exception {
     Change.Id changeId = changeOperations.newChange().project(project).create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 67e62dd..abfd7896 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -176,12 +176,12 @@
     assertThat(approval.postSubmit).isNull();
     assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 1, 2);
 
-    // Repeating the current label is allowed. Flips the postSubmit since technically this is a
-    // new vote.
+    // Repeating the current label is allowed. Does not flip the postSubmit bit due to
+    // deduplication codepath.
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isTrue();
+    assertThat(approval.postSubmit).isNull();
 
     // Reducing vote is not allowed.
     ResourceConflictException thrown =
@@ -193,7 +193,7 @@
         .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isTrue();
+    assertThat(approval.postSubmit).isNull();
 
     // Increasing vote is allowed.
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 64bd25c..5b18d02 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -27,7 +27,6 @@
     labels = [
         "docker",
         "elastic",
-        "exclusive",
         "pgm",
         "no_windows",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 888878f..ffde622 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -450,6 +450,23 @@
   }
 
   @Test
+  public void suggestNoServiceAccounts() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeIdReviewed = createChangeFromApi();
+    String changeId = createChangeFromApi();
+
+    String name = name("foo");
+    TestAccount foo = accountCreator.create(name);
+    reviewChange(changeIdReviewed, foo);
+
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo), ImmutableList.of());
+
+    groupOperations.group(serviceUsersUUID()).forUpdate().addMember(foo.id()).update();
+
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(), ImmutableList.of());
+  }
+
+  @Test
   public void suggestNoExistingReviewers() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     String changeId = createChangeFromApi();
@@ -608,6 +625,13 @@
     return user(name, fullName, name);
   }
 
+  private AccountGroup.UUID serviceUsersUUID() {
+    return groupCache
+        .get(AccountGroup.nameKey("Service Users"))
+        .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
+        .getGroupUUID();
+  }
+
   private void reviewChange(String changeId, TestAccount reviewer) throws RestApiException {
     gApi.changes().id(changeId).addReviewer(reviewer.id().toString());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 0a84db4..4738f64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -74,6 +74,7 @@
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+  @GerritConfig(name = "gerrit.instanceId", value = "devops-instance")
 
   // suggest
   @GerritConfig(name = "suggest.from", value = "3")
@@ -116,6 +117,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.instanceId).isEqualTo("devops-instance");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -184,6 +186,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.instanceId).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index be35d5a..3036811 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -55,7 +55,6 @@
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
-    "exclusive",
 ]
 
 [junit_tests(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 7efcb4b..48bd321 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -705,6 +705,24 @@
   }
 
   @Test
+  public void byParentOf() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
+    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
+    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
+    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
+    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
+    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+
+    assertQuery("parentof:" + change1.getId().get());
+    assertQuery("parentof:" + change1.getKey().get());
+    assertQuery("parentof:" + change2.getId().get(), change1);
+    assertQuery("parentof:" + change2.getKey().get(), change1);
+    assertQuery("parentof:" + change3.getId().get(), change2, change1);
+    assertQuery("parentof:" + change3.getKey().get(), change2, change1);
+  }
+
+  @Test
   public void byParentProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2", "repo1");
diff --git a/modules/jgit b/modules/jgit
index 4560bdf..9bfb0f3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 4560bdf7e2e3c16a7c7bb3f2fcf067bb1eee26fb
+Subproject commit 9bfb0f3a4ec856dcbebb477a1ee8803a3c47c194
diff --git a/plugins/replication b/plugins/replication
index ab80790..14766e7 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit ab8079055a92fa4068a2982306c11425f347e12f
+Subproject commit 14766e75f91886ab48951035d59a78c8c3f07471
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index c046b4f..e58bdd5 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -14,18 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CoverageRange, Side} from './diff';
+import {CoverageRange, GrDiffLine, Side} from './diff';
 import {StyleObject} from './styles';
 
-export type AddLayerFunc = (ctx: AnnotationContext) => void;
-
-export type NotifyFunc = (
-  path: string,
-  start: number,
-  end: number,
-  side: Side
-) => void;
-
+/**
+ * This is the callback object that Gerrit calls once for each diff. Gerrit
+ * is then responsible for styling the diff according the returned array of
+ * CoverageRanges.
+ */
 export type CoverageProvider = (
   changeNum: number,
   path: string,
@@ -34,14 +30,35 @@
   /**
    * This is a ChangeInfo object as defined here:
    * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-   * We neither want to repeat it nor add a dependency on it here.
+   * At the moment we neither want to repeat it nor add a dependency on it here.
+   * TODO: Create a dedicated smaller object for exposing a change in the plugin
+   * API. Or allow the plugin API to depend on the entire rest API.
    */
   change?: unknown
 ) => Promise<Array<CoverageRange>>;
 
+export type AnnotationCallback = (ctx: AnnotationContext) => void;
+
+/**
+ * This object is passed to the plugin from Gerrit for each line of a diff that
+ * is being rendered. The plugin can then call annotateRange() or
+ * annotateLineNumber() to apply additional styles to the diff.
+ */
 export interface AnnotationContext {
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly changeNum: number;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly path: string;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly line: GrDiffLine;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly contentEl: HTMLElement;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly lineNumberEl: HTMLElement;
+
   /**
-   * Method to add annotations to a content line.
+   * Can be called by the plugin to style a part of the given line of the
+   * context.
    *
    * @param offset The char offset where the update starts.
    * @param length The number of chars that the update covers.
@@ -56,7 +73,8 @@
   ): void;
 
   /**
-   * Method to add a CSS class to the line number TD element.
+   * Can be called by the plugin to style a part of the given line of the
+   * context.
    *
    * @param styleObject The style object for the range.
    * @param side The side of the update. ('left' or 'right')
@@ -66,23 +84,12 @@
 
 export interface AnnotationPluginApi {
   /**
-   * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange and
-   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
-   * line content or the line number.
-   *
-   * @param addLayerFunc The function
-   * that will be called when the AnnotationLayer is ready to annotate.
+   * Registers a callback for applying annotations. Gerrit will call the
+   * callback for every line of every file that is rendered and pass the
+   * information about the file and line as an AnnotationContext, which also
+   * provides methods for the plugin to style the content.
    */
-  addLayer(addLayerFunc: AddLayerFunc): AnnotationPluginApi;
-
-  /**
-   * The specified function will be called with a notify function for the plugin
-   * to call when it has all required data for annotation. Optional.
-   *
-   * @param notifyFunc See doc of the notify function below to see what it does.
-   */
-  addNotifier(notifyFunc: (n: NotifyFunc) => void): AnnotationPluginApi;
+  setLayer(callback: AnnotationCallback): AnnotationPluginApi;
 
   /**
    * The specified function will be called when a gr-diff component is built,
@@ -117,9 +124,10 @@
   ): AnnotationPluginApi;
 
   /**
-   * The notify function will call the listeners of all required annotation
-   * layers. Intended to be called by the plugin when all required data for
-   * annotation is available.
+   * For plugins notifying Gerrit about new annotations being ready to be
+   * applied for a certain range. Gerrit will then re-render the relevant lines
+   * of the diff and call back to the layer annotation function that was
+   * registered in addLayer().
    *
    * @param path The file path whose listeners should be notified.
    * @param start The line where the update starts.
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 143fbd1..4a5ef7e 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -43,6 +43,12 @@
   fetchPollingIntervalSeconds: number;
 }
 
+export interface ChangeData {
+  changeNumber: number;
+  patchsetNumber: number;
+  repo: string;
+}
+
 export interface ChecksProvider {
   /**
    * Gerrit calls this method when ...
@@ -51,7 +57,7 @@
    * - ... while the tab is visible in a regular polling interval, see
    *       ChecksApiConfig.
    */
-  fetch(change: number, patchset: number): Promise<FetchResponse>;
+  fetch(change: ChangeData): Promise<FetchResponse>;
 }
 
 export interface FetchResponse {
@@ -224,13 +230,24 @@
 export type ActionCallback = (
   change: number,
   patchset: number,
+  /**
+   * Identical to 'attempt' property of CheckRun. Not set for top-level
+   * actions.
+   */
   attempt: number | undefined,
+  /**
+   * Identical to 'externalId' property of CheckRun. Not set for top-level
+   * actions.
+   */
   externalId: string | undefined,
-  /** Identical to 'checkName' property of CheckRun. */
-  checkName: string,
+  /**
+   * Identical to 'checkName' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  checkName: string | undefined,
   /** Identical to 'name' property of Action entity. */
   actionName: string
-) => Promise<ActionResult>;
+) => Promise<ActionResult> | undefined;
 
 export interface ActionResult {
   /** An empty errorMessage means success. */
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index e77a5e8..4fa84eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -95,7 +95,7 @@
   @property({type: String})
   _filter = '';
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 8a3e47f..f2b4c89 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -99,7 +99,7 @@
     return htmlTemplate;
   }
 
-  private _account?: AccountDetailInfo;
+  private account?: AccountDetailInfo;
 
   @property({type: Object})
   params?: AdminViewParams;
@@ -173,7 +173,7 @@
   @property({type: Boolean})
   _showPluginList?: boolean;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   private readonly jsAPI = appContext.jsApiService;
 
@@ -189,7 +189,7 @@
       getPluginLoader().awaitPluginsLoaded(),
     ];
     return Promise.all(promises).then(result => {
-      this._account = result[0];
+      this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
       if (this._repoName) {
         options = {repoName: this._repoName};
@@ -204,7 +204,7 @@
       }
 
       return getAdminLinks(
-        this._account,
+        this.account,
         () =>
           this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
@@ -400,7 +400,7 @@
       }
       return '';
     }
-    // TODO(TS): The following condtion seems always false, because params
+    // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
       ((params as unknown) as AdminSubsectionLink).detailType &&
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index b68f720..e68f6c9 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -44,7 +44,7 @@
   @property({type: Boolean})
   _groupCreated = false;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   _computeGroupUrl(groupId: string) {
     return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index d50d7c5..6334670 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -68,7 +68,7 @@
     this.hasNewItemName = !!name;
   }
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   handleCreateItem() {
     if (!this.repoName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index c9fd241..f708485 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -70,7 +70,7 @@
   @property({type: Object})
   _queryGroups: AutocompleteQuery;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index bc4750f..201b340 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -55,7 +55,7 @@
   @property({type: Boolean})
   _loading = true;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 451139c..54f58c2 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -117,7 +117,7 @@
 
   _itemId?: AccountId | GroupId;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 6f00445..84daef8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -126,7 +126,7 @@
   @property({type: Boolean})
   _isAdmin = false;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 5702bfb..85ba052 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -140,7 +140,7 @@
   @property({type: Boolean})
   _originalExclusiveValue?: boolean;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index fc1ceee..f5e9a92 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -77,7 +77,7 @@
   @property({type: String})
   _filter = '';
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 935d091..56c5733 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -118,9 +118,9 @@
   @property({type: Boolean})
   _loading = true;
 
-  private _originalInheritsFrom?: ProjectInfo | null;
+  private originalInheritsFrom?: ProjectInfo | null;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
@@ -173,7 +173,7 @@
               ...res.inherits_from,
             }
           : null;
-        this._originalInheritsFrom = res.inherits_from
+        this.originalInheritsFrom = res.inherits_from
           ? {
               ...res.inherits_from,
             }
@@ -306,7 +306,7 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      this._inheritsFrom = {...this._originalInheritsFrom};
+      this._inheritsFrom = {...this.originalInheritsFrom};
       this._inheritFromFilter =
         'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
     }
@@ -446,8 +446,8 @@
       remove: {},
     };
 
-    const originalInheritsFromId = this._originalInheritsFrom
-      ? singleDecodeURL(this._originalInheritsFrom.id)
+    const originalInheritsFromId = this.originalInheritsFrom
+      ? singleDecodeURL(this.originalInheritsFrom.id)
       : null;
     // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
     // _inheritsFrom can be {}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 14cf234..f209729 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -91,7 +91,7 @@
   @property({type: Boolean})
   _runningGC = false;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 5f6cd29..7b3c7fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -50,7 +50,7 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   _repoChanged(repo?: RepoName) {
     this._loading = true;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index c1c8475..d6aa0e6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -88,7 +88,7 @@
     return this.computeShownItems(this._repos);
   }
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index bcc6039..b6881ff 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -141,7 +141,7 @@
   @property({type: Array})
   weblinks: WebLinkInfo[] = [];
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index b0065d9..9cc6357 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -192,7 +192,7 @@
       };
       element.section = 'refs/*';
 
-      // Typically called on ready since elements will have properies defined
+      // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
       flush();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 441d514..42741fa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -111,7 +111,7 @@
   @property({type: String})
   _repo: string | null = null;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   created() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index f36df84..f26cd46 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -146,7 +146,7 @@
 
   flagsService = appContext.flagsService;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   keyboardShortcuts() {
     return {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
index 9812933..add7ca5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -36,7 +36,7 @@
       width: 10em;
     }
     #graphic iron-icon {
-      color: #9e9e9e;
+      color: var(--gray-foreground);
       height: 5em;
       width: 5em;
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 841ee6e..a34bd63 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -58,7 +58,7 @@
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export interface GrDashboardView {
@@ -121,7 +121,7 @@
 
   private reporting = appContext.reportingService;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   private lastVisibleTimestampMs = 0;
 
@@ -380,6 +380,19 @@
       e.detail.change._number,
       e.detail.starred
     );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.starred`,
+            e.detail.starred
+          );
+        }
+      })
+    );
   }
 
   _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
@@ -387,6 +400,19 @@
       e.detail.change._number,
       e.detail.reviewed
     );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.reviewed`,
+            e.detail.reviewed
+          );
+        }
+      })
+    );
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 47f885b..a5de72b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -332,6 +332,56 @@
     });
   });
 
+  test('toggling star will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', starred: false};
+    const sameChange = {id: '5', starred: false};
+    const differentChange = {id: '4', starred: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleStar(
+        new CustomEvent('toggle-star', {
+          detail: {
+            change,
+            starred: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.starred);
+    assert.isTrue(sameChange.starred);
+    assert.isFalse(differentChange.starred);
+  });
+
+  test('toggling reviewed will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', reviewed: false};
+    const sameChange = {id: '5', reviewed: false};
+    const differentChange = {id: '4', reviewed: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleReviewed(
+        new CustomEvent('toggle-reviewed', {
+          detail: {
+            change,
+            reviewed: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.reviewed);
+    assert.isTrue(sameChange.reviewed);
+    assert.isFalse(differentChange.reviewed);
+  });
+
   test('_showNewUserHelp', () => {
     element._loading = false;
     element._showNewUserHelp = false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 10f65a5..cfee0cd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -54,7 +54,7 @@
   @property({type: String})
   _status = '';
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   _accountChanged(userId?: AccountId) {
     if (!userId) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 89ca4ef..87b09c7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -553,7 +553,7 @@
   @property({type: Object})
   _config?: ServerInfo;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   created() {
@@ -2068,10 +2068,10 @@
    *
    */
   _waitForChangeReachable(changeNum: NumericChangeId) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
-        attempsRemaining--;
+        attemptsRemaining--;
         // Pass a no-op error handler to avoid the "not found" error toast.
         this.restApiService
           .getChange(changeNum, () => {})
@@ -2082,7 +2082,7 @@
               return;
             }
 
-            if (attempsRemaining) {
+            if (attemptsRemaining) {
               this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
             } else {
               resolve(false);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 6c9a27d..fbb70b7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -2110,7 +2110,7 @@
 
       element = basicFixture.instantiate();
       // getChangeRevisionActions is not called without
-      // set the following properies
+      // set the following properties
       element.change = {};
       element.changeNum = '42';
       element.latestPatchNum = '2';
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index ef4d323..68e2368 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -210,6 +210,8 @@
 
   restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   /** @override */
   ready() {
     super.ready();
@@ -567,6 +569,10 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'metadata',
+      toState: this._showAllSections ? 'Show all' : 'Show less',
+    });
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index c5c73c5..59bf8ad 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -78,7 +78,7 @@
     }
     .icon.help,
     .icon.notTrusted {
-      color: #ffa62f;
+      color: var(--warning-foreground);
     }
     .icon.invalid {
       color: var(--negative-red-text-color);
@@ -87,7 +87,7 @@
       color: var(--positive-green-text-color);
     }
     .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: #ffa62f;
+      --arrow-color: var(--warning-foreground);
       display: inline-block;
     }
     .separatedSection {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index c0e87f3..e3fdf7a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -37,6 +37,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {labelCompare} from '../../../utils/label-util';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -93,6 +94,8 @@
     KnownExperimentId.NEW_CHANGE_SUMMARY_UI
   );
 
+  private readonly reporting = appContext.reportingService;
+
   _computeShowWip(change: ChangeInfo) {
     return change.work_in_progress;
   }
@@ -136,7 +139,7 @@
     const labels = labelsRecord.base || {};
     const allLabels: Label[] = [];
 
-    for (const label of Object.keys(labels).sort()) {
+    for (const label of Object.keys(labels).sort(labelCompare)) {
       allLabels.push({
         labelName: label,
         icon: this._computeLabelIcon(labels[label]),
@@ -192,6 +195,10 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'optional labels',
+      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
+    });
   }
 
   _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index f172ccc..a502949 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -23,7 +23,7 @@
       width: 100%;
     }
     .status {
-      color: #ffa62f;
+      color: var(--warning-foreground);
       display: inline-block;
       text-align: center;
       vertical-align: top;
@@ -99,6 +99,9 @@
       padding-left: 0;
     }
   </style>
+  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+    <h3 class="metadata-title">Submit requirements</h3>
+  </template>
   <template is="dom-repeat" items="[[_requirements]]">
     <gr-endpoint-decorator
       class="submit-requirement-endpoints"
@@ -126,9 +129,6 @@
       </div>
     </gr-endpoint-decorator>
   </template>
-  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
-    <h3 class="metadata-title">Submit requirements</h3>
-  </template>
   <template is="dom-repeat" items="[[_requiredLabels]]">
     <section>
       <div class="title">
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 72ed6e6..427924e 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
@@ -22,7 +22,8 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   allRuns$,
-  aPluginHasRegistered,
+  aPluginHasRegistered$,
+  someProvidersAreLoading$,
 } from '../../../services/checks/checks-model';
 import {
   Category,
@@ -40,6 +41,7 @@
   iconForCategory,
   iconForStatus,
   isRunning,
+  isRunningOrHasCompleted,
 } from '../../../services/checks/checks-util';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
@@ -55,7 +57,7 @@
 import {notUndefined} from '../../../types/types';
 import {uniqueDefinedAvatar} from '../../../utils/account-util';
 import {PrimaryTab} from '../../../constants/constants';
-import {CommentTabState} from '../../../types/events';
+import {ChecksTabState, CommentTabState} from '../../../types/events';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -75,6 +77,8 @@
   @property()
   category?: CommentTabState;
 
+  private readonly reporting = appContext.reportingService;
+
   static get styles() {
     return [
       sharedStyles,
@@ -109,13 +113,6 @@
         .summaryChip.check iron-icon {
           color: var(--gray-foreground);
         }
-        .summaryChip.info {
-          border-color: var(--info-deemphasized-foreground;
-          background-color: var(--info-deemphasized-background);
-        }
-        .summaryChip.info iron-icon {
-          color: var(--info-deemphasized-foreground);
-        }
       `,
     ];
   }
@@ -136,6 +133,9 @@
   private handleClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
     fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
       commentTab: this.category,
     });
@@ -228,19 +228,13 @@
     const chipClass = `checksChip font-small ${this.icon}`;
     const grIcon = `gr-icons:${this.icon}`;
     return html`
-      <div class="${chipClass}" role="button" @click="${this.handleClick}">
+      <div class="${chipClass}" role="button">
         <iron-icon icon="${grIcon}"></iron-icon>
         <div class="text">${this.text}</div>
         <slot></slot>
       </div>
     `;
   }
-
-  private handleClick(e: MouseEvent) {
-    e.stopPropagation();
-    e.preventDefault();
-    fireShowPrimaryTab(this, PrimaryTab.CHECKS);
-  }
 }
 
 /** What is the maximum number of expanded checks chips? */
@@ -267,13 +261,17 @@
   @property()
   showChecksSummary = false;
 
+  @property()
+  someProvidersAreLoading = false;
+
   /** Is reset when rendering beings and decreases while chips are rendered. */
   private detailsQuota = DETAILS_QUOTA;
 
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
-    this.subscribe('showChecksSummary', aPluginHasRegistered);
+    this.subscribe('showChecksSummary', aPluginHasRegistered$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
   }
 
   static get styles() {
@@ -288,6 +286,9 @@
         :host.new-change-summary-true {
           margin-bottom: var(--spacing-m);
         }
+        .zeroState {
+          color: var(--primary-text-color);
+        }
         td.key {
           padding-right: var(--spacing-l);
           padding-bottom: var(--spacing-m);
@@ -312,11 +313,17 @@
     ];
   }
 
+  renderChecksZeroState() {
+    if (this.runs.some(isRunningOrHasCompleted)) return;
+    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
+    return html`<span class="font-small zeroState">${msg}</span>`;
+  }
+
   renderChecksChipForCategory(category: Category) {
     const icon = iconForCategory(category);
     const runs = this.runs.filter(run => hasResultsOf(run, category));
     const count = (run: CheckRun) => getResultsOf(run, category);
-    return this.renderChecksChip(icon, runs, count);
+    return this.renderChecksChip(icon, runs, category, count);
   }
 
   renderChecksChipForStatus(
@@ -325,12 +332,13 @@
   ) {
     const icon = iconForStatus(status);
     const runs = this.runs.filter(filter);
-    return this.renderChecksChip(icon, runs, () => []);
+    return this.renderChecksChip(icon, runs, status, () => []);
   }
 
   renderChecksChip(
     icon: string,
     runs: CheckRun[],
+    statusOrCategory: RunStatus | Category,
     resultFilter: (run: CheckRun) => CheckResult[]
   ) {
     if (runs.length === 0) {
@@ -350,9 +358,10 @@
           class="${icon}"
           .icon="${icon}"
           .text="${text}"
+          @click="${() => this.onChipClick({checkName: run.checkName})}"
           >${links.map(
             link => html`
-              <a href="${link.url}" target="_blank" @click="${this.onClick}"
+              <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
                 ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
               ></a>
             `
@@ -371,11 +380,18 @@
       class="${icon}"
       .icon="${icon}"
       .text="${sum}"
+      @click="${() => this.onChipClick({statusOrCategory})}"
     ></gr-checks-chip>`;
   }
 
-  private onClick(e: MouseEvent) {
-    // Prevents handleClick() from reacting to <a> link clicks.
+  private onChipClick(state: ChecksTabState) {
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS, true, {
+      checksTab: state,
+    });
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
   }
 
@@ -395,7 +411,7 @@
           <tr ?hidden=${!this.showChecksSummary}>
             <td class="key">Checks</td>
             <td class="value">
-              ${this.renderChecksChipForCategory(
+              ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
                 Category.ERROR
               )}${this.renderChecksChipForCategory(
                 Category.WARNING
@@ -410,13 +426,13 @@
           <tr ?hidden=${!this.newChangeSummaryUiEnabled}>
             <td class="key">Comments</td>
             <td class="value">
-              <gr-summary-chip
-                styleType=${SummaryChipStyles.INFO}
+              <span
+                class="font-small zeroState"
                 ?hidden=${!!countResolvedComments ||
                 !!draftCount ||
                 !!countUnresolvedComments}
               >
-                No Comments</gr-summary-chip
+                No Comments</span
               ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 category=${CommentTabState.DRAFTS}
@@ -427,7 +443,6 @@
               ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 category=${CommentTabState.UNRESOLVED}
-                icon="message"
                 ?hidden=${!countUnresolvedComments}
               >
                 ${unresolvedAuthors.map(
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 7b80977..ab1c48f 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
@@ -108,6 +108,9 @@
   QuickLabelInfo,
   ApprovalInfo,
   ElementPropertyDeepChange,
+  ChangeId,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
@@ -118,7 +121,7 @@
   GrCommentApi,
   ChangeComments,
 } from '../../diff/gr-comment-api/gr-comment-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
@@ -168,7 +171,7 @@
 import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
-import {aPluginHasRegistered} from '../../../services/checks/checks-model';
+import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
 import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
@@ -271,11 +274,13 @@
    * @event show-auth-required
    */
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
-  readonly jsAPI = appContext.jsApiService;
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly changeService = appContext.changeService;
 
   /**
    * URL params passed from the router.
@@ -576,7 +581,6 @@
       [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
       [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
       [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
       [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
       [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
@@ -592,7 +596,7 @@
   /** @override */
   ready() {
     super.ready();
-    aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
@@ -821,8 +825,11 @@
       paperTabs.scrollIntoView();
     }
     if (paperTabs.selected !== activeIndex) {
+      // paperTabs.selected is undefined during rendering
+      if (paperTabs.selected !== undefined) {
+        this.reporting.reportInteraction('show-tab', {tabName});
+      }
       paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
     }
     return tabName;
   }
@@ -884,7 +891,7 @@
   }
 
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
     // Trim trailing whitespace from each line.
@@ -1425,7 +1432,7 @@
   }
 
   _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const hash = MSG_PREFIX + e.detail.id;
@@ -1707,7 +1714,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (this._patchRange.basePatchNum === ParentPatchSetNum) {
@@ -1721,7 +1728,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (this._patchRange.basePatchNum === ParentPatchSetNum) {
@@ -1735,7 +1742,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
@@ -1754,7 +1761,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1773,7 +1780,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
@@ -1919,7 +1926,7 @@
   }
 
   _getProjectConfig() {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     return this.restApiService
       .getProjectConfig(this._change.project)
       .then(config => {
@@ -2020,6 +2027,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
@@ -2294,7 +2302,27 @@
       const relatedChangesLoaded = coreDataPromise.then(() => {
         this.getRelatedChangesList()?.reload();
         if (this._isNewChangeSummaryUiEnabled) {
-          this.getRelatedChangesListExperimental()?.reload();
+          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
+          );
         }
       });
       allDataPromises.push(relatedChangesLoaded);
@@ -2311,11 +2339,26 @@
   }
 
   /**
+   * 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
+    );
+  }
+
+  /**
    * Kicks off requests for resources that rely on the patch range
    * (`this._patchRange`) being defined.
    */
   _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
-    if (!this._changeNum) throw new Error('missing changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
     if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
     const promises = [this._getCommitInfo(), this.$.fileList.reload()];
     if (rightPatchNumChanged)
@@ -2555,7 +2598,7 @@
     }
 
     this._updateCheckTimerHandle = this.async(() => {
-      if (!this._change) throw new Error('missing required change property');
+      assertIsDefined(this._change, '_change');
       const change = this._change;
       fetchChangeUpdates(change, this.restApiService).then(result => {
         let toastMessage = null;
@@ -2663,7 +2706,7 @@
       GrEditControls
     >('#editControls');
     if (!controls) throw new Error('Missing edit controls');
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const path = e.detail.path;
@@ -2698,7 +2741,7 @@
     if (!this._selectedRevision) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
 
     let patchNum: PatchSetNum;
     if (patchNumStr === 'edit') {
@@ -2746,7 +2789,7 @@
   }
 
   _handleStopEditTap() {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -2781,7 +2824,7 @@
   /**
    * Wrapper for using in the element template and computed properties
    */
-  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+  _computeLatestPatchNum(allPatchSets?: PatchSet[]) {
     return computeLatestPatchNum(allPatchSets);
   }
 
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 56c1d38..08c04e8 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
@@ -105,7 +105,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-s);
+      margin-bottom: var(--spacing-m);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -690,7 +690,10 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
-        <gr-checks-tab id="checksTab"></gr-checks-tab>
+        <gr-checks-tab
+          id="checksTab"
+          tab-state="[[_tabState.checksTab]]"
+        ></gr-checks-tab>
       </template>
       <template
         is="dom-if"
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 99e5356..ae446cd 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
@@ -105,6 +105,7 @@
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {appContext} from '../../../services/app-context';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -2766,7 +2767,7 @@
     element._change = {...change};
     element._patchRange = {patchNum: 4 as PatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(element.jsAPI, 'handleEvent');
+    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2968,11 +2969,11 @@
 
     test("don't report changeDisplayed on reply", done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2985,11 +2986,11 @@
 
     test('report changeDisplayed on _paramsChanged', done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._paramsChanged({
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 10cffba..225e3e9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -144,7 +144,7 @@
 
   private selectedChangeIds = new Set<ChangeInfoId>();
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
index 4de395c..6c0099e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -46,11 +46,12 @@
     }
     .cherryPickTopicLayout {
       display: flex;
+      align-items: center;
+      margin-bottom: var(--spacing-m);
     }
     .cherryPickSingleChange,
     .cherryPickTopic {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     .cherry-pick-topic-message {
       margin-bottom: var(--spacing-m);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index ff61de9..5e95e66 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -67,7 +67,7 @@
     };
   }
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 9dcd849..b2b6e61 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -89,7 +89,7 @@
   @property({type: Array})
   _recentChanges?: RebaseChange[];
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 99094d2..29b3752 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-l);
     }
     .warningBeforeSubmit {
-      color: var(--error-text-color);
+      color: var(--warning-foreground);
       vertical-align: top;
       margin-right: var(--spacing-s);
     }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index a5c0624..c2947a6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -803,7 +803,7 @@
   }
 
   /**
-   * Handle all events from the file list dom-repeat so event handleers don't
+   * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index 23718fa..c57a2d5 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -154,7 +154,7 @@
       padding-left: var(--spacing-s);
     }
     .drafts {
-      color: #c62828;
+      color: var(--error-foreground);
       font-weight: var(--font-weight-bold);
     }
     .show-hide-icon:focus {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 285b73f..80d8729 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -1244,7 +1244,7 @@
       // are no deletions.
       assert.equal(element._computeBarAdditionWidth(file, stats), 30);
 
-      // If there are no insetions, there is no width.
+      // If there are no insertions, there is no width.
       stats.maxInserted = 0;
       assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index a966186..661cd1a 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -37,6 +37,7 @@
 } from '../gr-label-score-row/gr-label-score-row';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
+import {labelCompare} from '../../../utils/label-util';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends GestureEventListeners(
@@ -147,7 +148,7 @@
     if (!labelRecord?.base) return [];
     const labelsObj = labelRecord.base;
     return Object.keys(labelsObj)
-      .sort()
+      .sort(labelCompare)
       .map(key => {
         return {
           name: key,
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 39cdf22..f913459 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -40,6 +40,7 @@
   NumericChangeId,
   ChangeMessageId,
   PatchSetNum,
+  AccountInfo,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -51,8 +52,9 @@
   computeLatestPatchNum,
   computePredecessor,
 } from '../../../utils/patch-set-util';
+import {isServiceUser} from '../../../utils/account-util';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -472,16 +474,17 @@
     return classes.join(' ');
   }
 
-  _computeClass(expanded: boolean) {
+  _computeClass(expanded?: boolean, author?: AccountInfo) {
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
+    if (isServiceUser(author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
     // The element which triggers _handleAnchorClick is rendered only if
-    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 0409a42..8d03e32 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -39,11 +39,24 @@
     .contentContainer {
       padding: var(--spacing-m) var(--spacing-l);
     }
+    .expanded .contentContainer {
+      background-color: var(--background-color-secondary);
+    }
     .collapsed .contentContainer {
-      /* For expanded state we inherit the alternating background color
-           that is set in gr-messages-list. */
       background-color: var(--background-color-primary);
     }
+    div.serviceUser.expanded div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-secondary)
+      );
+    }
+    div.serviceUser.collapsed div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-primary)
+      );
+    }
     .name {
       font-weight: var(--font-weight-bold);
     }
@@ -188,7 +201,7 @@
       }
     }
   </style>
-  <div class$="[[_computeClass(_expanded)]]">
+  <div class$="[[_computeClass(_expanded, author)]]">
     <div class="contentContainer">
       <div class="author" on-click="_handleAuthorClick">
         <span hidden$="[[!showOnBehalfOf]]">
@@ -292,7 +305,7 @@
           </gr-button>
         </template>
         <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]]</span>
+          <span class="patchset">[[message._revision_number]] |</span>
         </template>
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 9877a95..bc3f167 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -118,7 +118,7 @@
 
 /**
  * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
+ * a message is initially hidden or not, see isImportant(). So we are applying
  * some "magic" rules here in order to hide exactly the right messages.
  *
  * 1. If a message does not have a tag, but is associated with robot comments,
@@ -263,7 +263,7 @@
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
   private readonly reporting = appContext.reportingService;
 
@@ -466,7 +466,7 @@
       LabelNameToInfoMap
     >
   ) {
-    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const extremes: {[labelName: string]: VotingRangeInfo} = {};
     const labels = labelRecord.base;
     if (!labels) {
       return extremes;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index e1ef3f8..6c786ef 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -50,9 +50,6 @@
     gr-message:not(:last-of-type) {
       border-bottom: 1px solid var(--border-color);
     }
-    gr-message {
-      background-color: var(--background-color-secondary);
-    }
   </style>
   <div class="header">
     <div id="showAllActivityToggleContainer" class="container">
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-experimental/gr-related-change.ts
index 3ed545e..7b698f1 100644
--- 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-experimental/gr-related-change.ts
@@ -77,10 +77,10 @@
           margin-left: var(--spacing-xs);
         }
         .notCurrent {
-          color: #e65100;
+          color: var(--warning-foreground);
         }
         .indirectAncestor {
-          color: #33691e;
+          color: var(--indirect-ancestor-text-color);
         }
         .submittableCheck {
           padding-left: var(--spacing-s);
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
index fbbef12..214271af 100644
--- 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
@@ -16,9 +16,12 @@
  */
 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} from 'lit-element';
+import {customElement, property, css, internalProperty} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   SubmittedTogetherInfo,
@@ -32,7 +35,11 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {pluralize} from '../../../utils/string-util';
-import {getRevisionKey, isChangeInfo} from '../../../utils/change-util';
+import {
+  changeIsOpen,
+  getRevisionKey,
+  isChangeInfo,
+} from '../../../utils/change-util';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const MAX_CHANGES_WHEN_COLLAPSED = 3;
@@ -46,13 +53,25 @@
   patchNum?: PatchSetNum;
 
   @property()
-  _submittedTogether?: SubmittedTogetherInfo = {
+  mergeable?: boolean;
+
+  @internalProperty()
+  submittedTogether?: SubmittedTogetherInfo = {
     changes: [],
     non_visible_changes: 0,
   };
 
-  @property()
-  _relatedResponse?: RelatedChangesInfo = {changes: []};
+  @internalProperty()
+  relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+  @internalProperty()
+  conflictingChanges: ChangeInfo[] = [];
+
+  @internalProperty()
+  cherryPickChanges: ChangeInfo[] = [];
+
+  @internalProperty()
+  sameTopicChanges: ChangeInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
@@ -62,6 +81,7 @@
       css`
         .note {
           color: var(--error-text-color);
+          margin-left: 1.2em;
         }
         section {
           margin-bottom: var(--spacing-m);
@@ -71,27 +91,26 @@
   }
 
   render() {
-    const relatedChanges = this._relatedResponse?.changes ?? [];
     let showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
-      relatedChanges.length,
-      relatedChanges.findIndex(relatedChange =>
+      this.relatedChanges.length,
+      this.relatedChanges.findIndex(relatedChange =>
         this._changesEqual(relatedChange, this.change)
       )
     );
     const connectedRevisions = this._computeConnectedRevisions(
       this.change,
       this.patchNum,
-      relatedChanges
+      this.relatedChanges
     );
     const relatedChangeSection = html` <section
       class="relatedChanges"
-      ?hidden=${!relatedChanges.length}
+      ?hidden=${!this.relatedChanges.length}
     >
       <gr-related-collapse
         title="Relation chain"
-        .length=${relatedChanges.length}
+        .length=${this.relatedChanges.length}
       >
-        ${relatedChanges.map(
+        ${this.relatedChanges.map(
           (change, index) =>
             html`<gr-related-change
               class="${classMap({
@@ -114,9 +133,9 @@
       </gr-related-collapse>
     </section>`;
 
-    const submittedTogetherChanges = this._submittedTogether?.changes ?? [];
+    const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
     const countNonVisibleChanges =
-      this._submittedTogether?.non_visible_changes ?? 0;
+      this.submittedTogether?.non_visible_changes ?? 0;
     showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
       submittedTogetherChanges.length,
       submittedTogetherChanges.findIndex(relatedChange =>
@@ -126,7 +145,7 @@
     const submittedTogetherSection = html`<section
       id="submittedTogether"
       ?hidden=${!submittedTogetherChanges?.length &&
-      !this._submittedTogether?.non_visible_changes}
+      !this.submittedTogether?.non_visible_changes}
     >
       <gr-related-collapse
         title="Submitted together"
@@ -155,11 +174,106 @@
       </div>
     </section>`;
 
-    return html`${relatedChangeSection}${submittedTogetherSection}`;
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.sameTopicChanges.length,
+      -1
+    );
+    const sameTopicSection = html`<section
+      id="sameTopic"
+      ?hidden=${!this.sameTopicChanges?.length}
+    >
+      <gr-related-collapse
+        title="Same topic"
+        .length=${this.sameTopicChanges.length}
+      >
+        ${this.sameTopicChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              >${change.project}: ${change.branch}:
+              ${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.conflictingChanges.length,
+      -1
+    );
+    const mergeConflictsSection = html`<section
+      id="mergeConflicts"
+      ?hidden=${!this.conflictingChanges?.length}
+    >
+      <gr-related-collapse
+        title="Merge conflicts"
+        .length=${this.conflictingChanges.length}
+      >
+        ${this.conflictingChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              >${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.cherryPickChanges.length,
+      -1
+    );
+    const cherryPicksSection = html`<section
+      id="cherryPicks"
+      ?hidden=${!this.cherryPickChanges?.length}
+    >
+      <gr-related-collapse
+        title="Cherry picks"
+        .length=${this.cherryPickChanges.length}
+      >
+        ${this.cherryPickChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .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="[[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>`;
   }
 
   showWhenCollapsedPredicateFactory(length: number, highlightIndex: number) {
     return (index: number) => {
+      if (highlightIndex === -1) return index < MAX_CHANGES_WHEN_COLLAPSED;
       if (highlightIndex === 0) return index <= MAX_CHANGES_WHEN_COLLAPSED - 1;
       if (highlightIndex === length - 1)
         return index >= length - MAX_CHANGES_WHEN_COLLAPSED;
@@ -170,25 +284,65 @@
     };
   }
 
-  reload() {
-    if (!this.change) return Promise.reject(new Error('change missing'));
+  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
-        .getRelatedChanges(this.change._number, this.patchNum)
+        .getChangesSubmittedTogether(change._number)
         .then(response => {
-          if (!response) {
-            throw new Error('getRelatedChanges returned undefined response');
-          }
-          this._relatedResponse = response;
+          this.submittedTogether = response;
         }),
       this.restApiService
-        .getChangesSubmittedTogether(this.change._number)
+        .getChangeCherryPicks(change.project, change.change_id, change._number)
         .then(response => {
-          this._submittedTogether = 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);
   }
 
@@ -275,6 +429,8 @@
   @property()
   length = 0;
 
+  private readonly reporting = appContext.reportingService;
+
   static get styles() {
     return [
       sharedStyles,
@@ -355,6 +511,10 @@
   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',
+    });
   }
 }
 
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 b4b6d31..5705c4f 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
@@ -451,12 +451,14 @@
       ?.getElementsByClassName('arrowToCurrentChange')[0]
       ?.nextElementSibling?.nextElementSibling?.getElementsByTagName('a')[0];
 
-    if (!target || !currentChange) return;
+    if (!target) return;
     this.reportingService.reportInteraction('related-change-click', {
       sectionName,
       index: sectionLinks.indexOf(target) + 1,
       countChanges: sectionLinks.length,
-      currentChangeIndex: sectionLinks.indexOf(currentChange) + 1,
+      currentChangeIndex: !currentChange
+        ? undefined
+        : sectionLinks.indexOf(currentChange) + 1,
     });
   }
 }
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
index 8cb0638..2f53319 100644
--- 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
@@ -66,10 +66,10 @@
       margin-left: var(--spacing-xs);
     }
     .notCurrent {
-      color: #e65100;
+      color: var(--warning-foreground);
     }
     .indirectAncestor {
-      color: #33691e;
+      color: var(--indirect-ancestor-text-color);
     }
     .submittableCheck {
       padding-left: var(--spacing-s);
@@ -193,6 +193,7 @@
               href$="[[_computeChangeURL(change._number, change.project)]]"
               class$="[[_computeLinkClass(change)]]"
               title$="[[change.subject]]"
+              on-click="_reportClick"
             >
               [[change.subject]]
             </a>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 36764f3..a8e89b6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -92,6 +92,7 @@
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
+  assertIsDefined,
   assertNever,
   containsAll,
 } from '../../../utils/common-util';
@@ -433,7 +434,7 @@
   }
 
   open(focusTarget?: FocusTarget) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
     fetchChangeUpdates(this.change, this.restApiService).then(result => {
       this.knownLatestState = result.isLatest
@@ -605,7 +606,7 @@
     account: AccountInfoInput | GroupInfoInput,
     type: ReviewerType
   ) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (account._pendingAdd || !isAccount(account)) {
       return;
     }
@@ -783,7 +784,7 @@
   }
 
   _handle400Error(r?: Response | null) {
-    if (!r) throw new Error('Reponse is empty.');
+    if (!r) throw new Error('Response is empty.');
     let response: Response = r;
     // A call to _saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
@@ -1213,7 +1214,7 @@
   }
 
   cancel() {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (!this._owner) throw new Error('missing required _owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
@@ -1269,8 +1270,8 @@
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
-    if (!this.change) throw new Error('missing required change property');
-    if (!this.patchNum) throw new Error('missing required patchNum property');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchNum, 'patchNum');
     return this.restApiService.saveChangeReview(
       this.change._number,
       this.patchNum,
@@ -1316,7 +1317,7 @@
   }
 
   _getStorageLocation(): StorageLocation {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     return {
       changeNum: this.change._number,
       patchNum: '@change',
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 6682bfb..0575239 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -1276,7 +1276,7 @@
         'Send and Start review');
   });
 
-  test('_handle400Error reviewrs and CCs', done => {
+  test('_handle400Error reviewers and CCs', done => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index bc72b2a..ef2430c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -15,15 +15,34 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {css, customElement, property, PropertyValues} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks';
+import {
+  Category,
+  CheckRun,
+  Link,
+  LinkIcon,
+  RunStatus,
+  Tag,
+} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {RunResult} from '../../services/checks/checks-model';
 import {
+  hasCompleted,
   hasCompletedWithoutResults,
   iconForCategory,
+  isRunning,
 } from '../../services/checks/checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
+import {durationString} from '../../utils/date-util';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -36,6 +55,9 @@
   @property({type: Boolean, reflect: true})
   isExpandable = false;
 
+  @property()
+  shouldRender = false;
+
   static get styles() {
     return [
       sharedStyles,
@@ -79,7 +101,7 @@
         }
         td .summary-cell {
           display: flex;
-          max-width: calc(100vw - 579px);
+          max-width: calc(100vw - 700px);
         }
         td .summary-cell .summary {
           font-weight: var(--font-weight-bold);
@@ -124,8 +146,26 @@
     super.update(changedProperties);
   }
 
+  firstUpdated() {
+    const loading = this.shadowRoot?.querySelector('.container');
+    assertIsDefined(loading, '"Loading" element');
+    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+  }
+
   render() {
     if (!this.result) return '';
+    if (!this.shouldRender) {
+      return html`
+        <tr class="container">
+          <td class="iconCol"></td>
+          <td class="nameCol">
+            <div><span class="loading">Loading...</span></div>
+          </td>
+          <td class="summaryCol"></td>
+          <td class="expanderCol"></td>
+        </tr>
+      `;
+    }
     return html`
       <tr class="container" @click="${this.toggleExpanded}">
         <td class="iconCol">
@@ -136,7 +176,7 @@
         </td>
         <td class="summaryCol">
           <div class="summary-cell">
-            ${(this.result.links ?? []).map(this.renderLink)}
+            ${(this.result.links?.slice(0, 5) ?? []).map(this.renderLink)}
             ${this.renderSummary(this.result.summary)}
             <div class="message">
               ${this.isExpanded ? '' : this.result.message}
@@ -235,18 +275,36 @@
   render() {
     if (!this.result) return '';
     return html`
-      <div class="message">
-        ${this.result.message}
-      </div>
+      <gr-endpoint-decorator name="check-result-expanded">
+        <gr-endpoint-param
+          name="run"
+          value="${this.result}"
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="result"
+          value="${this.result}"
+        ></gr-endpoint-param>
+        <div class="message">
+          ${this.result.message}
+        </div>
+      </gr-endpoint-decorator>
     `;
   }
 }
 
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
   @property()
   runs: CheckRun[] = [];
 
+  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+
   static get styles() {
     return [
       sharedStyles,
@@ -255,27 +313,48 @@
           display: block;
           padding: var(--spacing-xl);
         }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          min-width: 400px;
+        }
         .categoryHeader {
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
           text-transform: capitalize;
+          cursor: default;
         }
-        .categoryHeader iron-icon {
+        .categoryHeader .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+          margin-right: var(--spacing-s);
+        }
+        .categoryHeader .statusIcon {
           position: relative;
-          top: 1px;
+          top: 2px;
         }
-        .categoryHeader iron-icon.error {
+        .categoryHeader .statusIcon.error {
           color: var(--error-foreground);
         }
-        .categoryHeader iron-icon.warning {
+        .categoryHeader .statusIcon.warning {
           color: var(--warning-foreground);
         }
-        .categoryHeader iron-icon.info {
+        .categoryHeader .statusIcon.info {
           color: var(--info-foreground);
         }
-        .categoryHeader iron-icon.success {
+        .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
+        .collapsed table {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        .noCompleted {
+          margin-top: var(--spacing-l);
+        }
         table.resultsTable {
           width: 100%;
           max-width: 1280px;
@@ -295,81 +374,133 @@
   render() {
     return html`
       <div><h2 class="heading-2">Results</h2></div>
+      ${this.renderFilter()} ${this.renderNoCompleted()}
       ${this.renderSection(Category.ERROR)}
       ${this.renderSection(Category.WARNING)}
-      ${this.renderSection(Category.INFO)} ${this.renderSuccess()}
+      ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
     `;
   }
 
-  renderSection(category: Category) {
-    const catString = category.toString().toLowerCase();
-    const runs = this.runs.filter(r =>
-      (r.results ?? []).some(res => res.category === category)
-    );
-    if (runs.length === 0) return;
+  renderFilter() {
+    if (this.runs.length === 0) return;
     return html`
-      <h3 class="categoryHeader heading-3">
-        <iron-icon
-          icon="gr-icons:${iconForCategory(category)}"
-          class="${catString}"
-        ></iron-icon>
-        ${catString}
-      </h3>
-      <table class="resultsTable">
-        <thead>
-          <tr class="headerRow">
-            <th class="iconCol"></th>
-            <th class="nameCol">Run</th>
-            <th class="summaryCol">Summary</th>
-            <th class="expanderCol"></th>
-          </tr>
-        </thead>
-        <tbody>
-          ${runs.map(run => this.renderRun(category, run))}
-        </tbody>
-      </table>
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter results by regular expression"
+        @input="${this.onInput}"
+      />
     `;
   }
 
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
+  renderNoCompleted() {
+    if (this.runs.some(hasCompleted)) return;
+    let text = 'No results';
+    if (this.runs.some(isRunning)) {
+      text = 'Checks are running ...';
+    }
+    return html`<div class="noCompleted">${text}</div>`;
+  }
+
+  renderSection(category: Category | 'SUCCESS') {
+    const catString = category.toString().toLowerCase();
+    let runs = this.runs;
+    if (category === 'SUCCESS') {
+      runs = runs
+        .filter(hasCompletedWithoutResults)
+        .filter(r => this.filterRegExp.test(r.checkName));
+    } else {
+      runs = runs.filter(r =>
+        (r.results ?? []).some(res => res.category === category)
+      );
+    }
+    if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+    return html`
+      <div class="${expandedClass}">
+        <h3
+          class="categoryHeader heading-3"
+          @click="${() => this.toggleExpanded(category)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon
+            icon="gr-icons:${iconForCategory(category)}"
+            class="statusIcon ${catString}"
+          ></iron-icon>
+          ${catString}
+        </h3>
+        <table class="resultsTable">
+          <thead>
+            <tr class="headerRow">
+              <th class="iconCol"></th>
+              <th class="nameCol">Run</th>
+              <th class="summaryCol">Summary</th>
+              <th class="expanderCol"></th>
+            </tr>
+          </thead>
+          <tbody>
+            ${runs.map(run =>
+              category === 'SUCCESS'
+                ? this.renderSuccessfulRun(run)
+                : this.renderRun(category, run)
+            )}
+          </tbody>
+        </table>
+      </div>
+    `;
+  }
+
+  toggleExpanded(category: Category | 'SUCCESS') {
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    this.isSectionExpanded.set(category, !expanded);
+    this.requestUpdate();
+  }
+
   renderRun(category: Category, run: CheckRun) {
     return html`${run.results
       ?.filter(result => result.category === category)
+      .filter(
+        result =>
+          this.filterRegExp.test(run.checkName) ||
+          this.filterRegExp.test(result.summary)
+      )
       .map(
         result =>
           html`<gr-result-row .result="${{...run, ...result}}"></gr-result-row>`
       )}`;
   }
 
-  renderSuccess() {
-    const runs = this.runs.filter(hasCompletedWithoutResults);
-    if (runs.length === 0) return;
-    return html`
-      <h3 class="categoryHeader heading-3">
-        <iron-icon
-          icon="gr-icons:check-circle-outline"
-          class="success"
-        ></iron-icon>
-        Success
-      </h3>
-      <table class="resultsTable">
-        <tr class="headerRow">
-          <th class="iconCol"></th>
-          <th class="nameCol">Run</th>
-          <th class="summaryCol">Summary</th>
-          <th class="expanderCol"></th>
-        </tr>
-        ${runs.map(run => this.renderSuccessfulRun(run))}
-      </table>
-    `;
-  }
-
   renderSuccessfulRun(run: CheckRun) {
     const adaptedRun: RunResult = {
       category: Category.INFO, // will not be used, but is required
       summary: run.statusDescription ?? '',
-      message: 'Completed without results.',
       ...run,
     };
+    if (!run.statusDescription) {
+      const start = run.scheduledTimestamp ?? run.startedTimestamp;
+      const end = run.finishedTimestamp;
+      let duration = '';
+      if (start && end) {
+        duration = ` in ${durationString(start, end, true)}`;
+      }
+      adaptedRun.message = `Completed without results${duration}.`;
+    }
+    if (run.statusLink) {
+      adaptedRun.links = [
+        {
+          url: run.statusLink,
+          primary: true,
+          icon: LinkIcon.EXTERNAL,
+        },
+      ];
+    }
     return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 4a34700..31f17724 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -14,16 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from 'lit-html';
+import {html, nothing} from 'lit-html';
 import {classMap} from 'lit-html/directives/class-map';
-import {css, customElement, property} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  query,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action, CheckRun, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   compareByWorstCategory,
+  fireActionTriggered,
+  iconForCategory,
   iconForRun,
   primaryRunAction,
+  worstCategory,
 } from '../../services/checks/checks-util';
 import {
   allRuns$,
@@ -34,15 +43,14 @@
   fakeRun4,
   updateStateSetResults,
 } from '../../services/checks/checks-model';
+import {assertIsDefined} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
 
-/* The RunSelectedEvent is only used locally to communicate from <gr-checks-run>
-   to its <gr-checks-runs> parent. */
-
-interface RunSelectedEventDetail {
+export interface RunSelectedEventDetail {
   checkName: string;
 }
 
-type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
 
 declare global {
   interface HTMLElementEventMap {
@@ -54,33 +62,6 @@
   target.dispatchEvent(
     new CustomEvent('run-selected', {
       detail: {checkName},
-      composed: false,
-      bubbles: false,
-    })
-  );
-}
-
-export interface ActionTriggeredEventDetail {
-  action: Action;
-  run: CheckRun;
-}
-
-export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'action-triggered': ActionTriggeredEvent;
-  }
-}
-
-function fireActionTriggered(
-  target: EventTarget,
-  action: Action,
-  run: CheckRun
-) {
-  target.dispatchEvent(
-    new CustomEvent('action-triggered', {
-      detail: {action, run},
       composed: true,
       bubbles: true,
     })
@@ -106,6 +87,11 @@
           margin-top: var(--spacing-s);
           cursor: pointer;
         }
+        .left {
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
         .name {
           font-weight: var(--font-weight-bold);
         }
@@ -127,16 +113,19 @@
         .chip.placeholder {
           border-left: var(--thick-border) solid var(--border-color);
         }
-        .chip.error iron-icon {
+        .chip.placeholder iron-icon {
+          display: none;
+        }
+        iron-icon.error {
           color: var(--error-foreground);
         }
-        .chip.warning iron-icon {
+        iron-icon.warning {
           color: var(--warning-foreground);
         }
-        .chip.info-outline iron-icon {
+        iron-icon.info-outline {
           color: var(--info-foreground);
         }
-        .chip.check-circle-outline iron-icon {
+        iron-icon.check-circle-outline {
           color: var(--success-foreground);
         }
         /* Additional 'div' for increased specificity. */
@@ -145,9 +134,21 @@
           background-color: var(--selected-background);
           padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
         }
+        div.chip.deselected {
+          border: 1px solid var(--gray-foreground);
+          background-color: transparent;
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
         div.chip.selected iron-icon {
           color: var(--selected-foreground);
         }
+        div.chip.deselected iron-icon {
+          color: var(--gray-foreground);
+        }
+        .chip.selected gr-button.action,
+        .chip.deselected gr-button.action {
+          display: none;
+        }
         gr-button.action {
           --padding: var(--spacing-xs) var(--spacing-m);
           /* The button should fit into the 20px line-height. The negative
@@ -160,21 +161,47 @@
     ];
   }
 
+  @query('.chip')
+  chipElement?: HTMLElement;
+
   @property()
   run!: CheckRun;
 
   @property()
   selected = false;
 
+  @property()
+  deselected = false;
+
+  @property()
+  shouldRender = false;
+
+  firstUpdated() {
+    assertIsDefined(this.chipElement, 'chip element');
+    whenVisible(
+      this.chipElement,
+      () => this.setAttribute('shouldRender', 'true'),
+      200
+    );
+  }
+
   render() {
-    const icon = this.selected ? 'check-circle' : iconForRun(this.run);
-    const classes = {chip: true, [icon]: true, selected: this.selected};
+    if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
+
+    const icon = this.selected ? 'filter' : iconForRun(this.run);
+    const classes = {
+      chip: true,
+      [icon]: true,
+      selected: this.selected,
+      deselected: this.deselected,
+    };
     const action = primaryRunAction(this.run);
 
     return html`
       <div @click="${this.handleChipClick}" class="${classMap(classes)}">
         <div class="left">
-          <iron-icon icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
         </div>
         <div class="right">
@@ -191,6 +218,20 @@
     `;
   }
 
+  /**
+   * For RUNNING we also want to render an icon representing the worst result
+   * that has been reported until now - if there are any results already.
+   */
+  renderAdditionalIcon() {
+    if (this.run.status !== RunStatus.RUNNING) return nothing;
+    const category = worstCategory(this.run);
+    if (!category) return nothing;
+    const icon = iconForCategory(category);
+    return html`
+      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+    `;
+  }
+
   private handleChipClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
@@ -206,10 +247,19 @@
 
 @customElement('gr-checks-runs')
 export class GrChecksRuns extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
   @property()
   runs: CheckRun[] = [];
 
-  private selectedRuns = new Set<string>();
+  @property()
+  selectedRuns: string[] = [];
+
+  private isSectionExpanded = new Map<RunStatus, boolean>();
 
   constructor() {
     super();
@@ -224,9 +274,29 @@
           display: block;
           padding: var(--spacing-xl);
         }
-        .statusHeader {
+        .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+        }
+        .sectionHeader {
           padding-top: var(--spacing-l);
           text-transform: capitalize;
+          cursor: default;
+        }
+        .sectionHeader h3 {
+          display: inline-block;
+        }
+        .collapsed .sectionRuns {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          width: 100%;
         }
         .testing {
           margin-top: var(--spacing-xxl);
@@ -248,6 +318,12 @@
   render() {
     return html`
       <h2 class="heading-2">Runs</h2>
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter runs by regular expression"
+        @input="${this.onInput}"
+      />
       ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
       ${this.renderSection(RunStatus.RUNNABLE)}
@@ -274,6 +350,11 @@
     `;
   }
 
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
   none() {
     updateStateSetResults('f0', []);
     updateStateSetResults('f1', []);
@@ -298,34 +379,43 @@
   renderSection(status: RunStatus) {
     const runs = this.runs
       .filter(r => r.status === status)
+      .filter(r => this.filterRegExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
     return html`
-      <div class="${status.toLowerCase()}">
-        <h3 class="statusHeader heading-3">${status.toLowerCase()}</h3>
-        ${runs.map(run => this.renderRun(run))}
+      <div class="${status.toLowerCase()} ${expandedClass}">
+        <div
+          class="sectionHeader"
+          @click="${() => this.toggleExpanded(status)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <h3 class="heading-3">${status.toLowerCase()}</h3>
+        </div>
+        <div class="sectionRuns">
+          ${runs.map(run => this.renderRun(run))}
+        </div>
       </div>
     `;
   }
 
+  toggleExpanded(status: RunStatus) {
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    this.isSectionExpanded.set(status, !expanded);
+    this.requestUpdate();
+  }
+
   renderRun(run: CheckRun) {
-    const selected = this.selectedRuns.has(run.checkName);
+    const selected = this.selectedRuns.includes(run.checkName);
+    const deselected = !selected && this.selectedRuns.length > 0;
     return html`<gr-checks-run
       .run="${run}"
       .selected="${selected}"
-      @run-selected="${this.handleRunSelected}"
+      .deselected="${deselected}"
     ></gr-checks-run>`;
   }
-
-  handleRunSelected(e: RunSelectedEvent) {
-    const checkName = e.detail.checkName;
-    if (this.selectedRuns.has(checkName)) {
-      this.selectedRuns.delete(checkName);
-    } else {
-      this.selectedRuns.add(checkName);
-    }
-    this.requestUpdate();
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d31ec53..9376a03 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -15,16 +15,42 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {css, customElement, property} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action, CheckResult, CheckRun} from '../../api/checks';
-import {allResults$, allRuns$} from '../../services/checks/checks-model';
+import {
+  allActions$,
+  allResults$,
+  allRuns$,
+  checksPatchsetNumber$,
+  someProvidersAreLoading$,
+} from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
 import {sharedStyles} from '../../styles/shared-styles';
-import {changeNum$, currentPatchNum$} from '../../services/change/change-model';
-import {NumericChangeId, PatchSetNum} from '../../types/common';
-import {ActionTriggeredEvent} from './gr-checks-runs';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
+import {NumericChangeId, PatchSetNumber} from '../../types/common';
+import {
+  ActionTriggeredEvent,
+  fireActionTriggered,
+} from '../../services/checks/checks-util';
+import {
+  assertIsDefined,
+  check,
+  checkRequiredProperty,
+} from '../../utils/common-util';
+import {RunSelectedEvent} from './gr-checks-runs';
+import {ChecksTabState} from '../../types/events';
+import {fireAlert} from '../../utils/event-util';
+import {appContext} from '../../services/app-context';
+import {from, timer} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -37,18 +63,37 @@
 
   results: CheckResult[] = [];
 
+  actions: Action[] = [];
+
   @property()
-  currentPatchNum: PatchSetNum | undefined = undefined;
+  tabState?: ChecksTabState;
+
+  @property()
+  checksPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
   changeNum: NumericChangeId | undefined = undefined;
 
+  @property()
+  someProvidersAreLoading = false;
+
+  @internalProperty()
+  selectedRuns: string[] = [];
+
+  private readonly checksService = appContext.checksService;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
+    this.subscribe('actions', allActions$);
     this.subscribe('results', allResults$);
-    this.subscribe('currentPatchNum', currentPatchNum$);
+    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
     this.subscribe('changeNum', changeNum$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
       this.handleActionTriggered(e.detail.action, e.detail.run)
@@ -63,17 +108,13 @@
           display: block;
         }
         .header {
-          display: block;
+          display: flex;
+          justify-content: space-between;
           padding: var(--spacing-m) var(--spacing-l);
           border-bottom: 1px solid var(--border-color);
         }
-        .header span {
-          display: inline-block;
-          color: var(--link-color);
-          padding: var(--spacing-s) var(--spacing-m);
-          margin-right: var(--spacing-l);
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
+        .action {
+          margin-left: var(--spacing-m);
         }
         .container {
           display: flex;
@@ -92,48 +133,145 @@
   }
 
   render() {
-    const ps = `Patchset ${this.currentPatchNum} (Latest)`;
+    const filteredRuns = this.runs.filter(
+      r =>
+        this.selectedRuns.length === 0 ||
+        this.selectedRuns.includes(r.checkName)
+    );
     return html`
       <div class="header">
-        <gr-dropdown-list
-          value="${ps}"
-          .items="${[
-            {
-              value: `${ps}`,
-              text: `${ps}`,
-            },
-          ]}"
-        ></gr-dropdown-list>
+        <div class="left">
+          <gr-dropdown-list
+            value="${this.checksPatchsetNumber}"
+            .items="${this.createPatchsetDropdownItems()}"
+            @value-change="${this.onPatchsetSelected}"
+          ></gr-dropdown-list>
+          <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
+        </div>
+        <div class="right">
+          ${this.actions.map(this.renderAction)}
+        </div>
       </div>
       <div class="container">
-        <gr-checks-runs class="runs" .runs="${this.runs}"></gr-checks-runs>
+        <gr-checks-runs
+          class="runs"
+          .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
+          @run-selected="${this.handleRunSelected}"
+        ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .runs="${this.runs}"
+          .runs="${filteredRuns}"
         ></gr-checks-results>
       </div>
     `;
   }
 
-  private handleActionTriggered(action: Action, run: CheckRun) {
+  private onPatchsetSelected(e: CustomEvent<{value: string}>) {
+    const patchset = Number(e.detail.value);
+    check(!isNaN(patchset), 'selected patchset must be a number');
+    this.checksService.setPatchset(patchset as PatchSetNumber);
+  }
+
+  private createPatchsetDropdownItems() {
+    return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
+      assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
+      const index = this.latestPatchsetNumber - i;
+      const postfix = index === this.latestPatchsetNumber ? ' (latest)' : '';
+      return {
+        value: `${index}`,
+        text: `Patchset ${index}${postfix}`,
+      };
+    });
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState')) {
+      const check = this.tabState?.checkName;
+      if (check) {
+        this.selectedRuns = [check];
+      }
+    }
+  }
+
+  renderAction(action: Action) {
+    return html`<gr-checks-top-level-action
+      .action="${action}"
+    ></gr-checks-top-level-action>`;
+  }
+
+  handleActionTriggered(action: Action, run?: CheckRun) {
     if (!this.changeNum) return;
-    if (!this.currentPatchNum) return;
-    // TODO(brohlfs): The callback is supposed to be returning a promise.
-    // A toast should be displayed until the promise completes. And then the
-    // data should be updated.
-    action.callback(
+    if (!this.checksPatchsetNumber) return;
+    const promise = action.callback(
       this.changeNum,
-      this.currentPatchNum as number,
-      run.attempt,
-      run.externalId,
-      run.checkName,
+      this.checksPatchsetNumber,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
       action.name
     );
+    // Plugins *should* return a promise, but you never know ...
+    if (promise?.then) {
+      const prefix = `Triggering action '${action.name}'`;
+      fireAlert(this, `${prefix} ...`);
+      from(promise)
+        // If the action takes longer than 5 seconds, then most likely the
+        // user is either not interested or the result not relevant anymore.
+        .pipe(takeUntil(timer(5000)))
+        .subscribe(result => {
+          if (result.errorMessage) {
+            fireAlert(this, `${prefix} failed with ${result.errorMessage}.`);
+          } else {
+            fireAlert(this, `${prefix} successful.`);
+            this.checksService.reloadForCheck(run?.checkName);
+          }
+        });
+    } else {
+      fireAlert(this, `Action '${action.name}' triggered.`);
+    }
+  }
+
+  handleRunSelected(e: RunSelectedEvent) {
+    this.toggleSelected(e.detail.checkName);
+  }
+
+  toggleSelected(checkName: string) {
+    if (this.selectedRuns.includes(checkName)) {
+      this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
+    } else {
+      this.selectedRuns = [...this.selectedRuns, checkName];
+    }
+  }
+}
+
+@customElement('gr-checks-top-level-action')
+export class GrChecksTopLevelAction extends GrLitElement {
+  @property()
+  action!: Action;
+
+  connectedCallback() {
+    super.connectedCallback();
+    checkRequiredProperty(this.action, 'action');
+  }
+
+  render() {
+    return html`
+      <gr-button link class="action" @click="${this.handleClick}"
+        >${this.action.name}</gr-button
+      >
+    `;
+  }
+
+  handleClick() {
+    fireActionTriggered(this, this.action);
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-checks-tab': GrChecksTab;
+    'gr-checks-top-level-action': GrChecksTopLevelAction;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index 3f6148b..092daa2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -21,7 +21,6 @@
 import {__testOnly_ErrorType} from './gr-error-manager.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {appContext} from '../../../services/app-context.js';
-import {createPreferences} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-error-manager');
 
@@ -41,8 +40,6 @@
           .returns(Promise.resolve({ok: true, status: 204}));
       getLoggedInStub = stubRestApi('getLoggedIn')
           .callsFake(() => appContext.authService.authCheck());
-      stubRestApi('getPreferences').returns(Promise.resolve(
-          createPreferences()));
       element = basicFixture.instantiate();
       element._authService.clearCache();
       toastSpy = sinon.spy(element, '_createToastAlert');
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index b43b3b0..bed07a6 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -304,7 +304,7 @@
         if (!config) {
           throw new Error('getConfig returned undefined');
         }
-        this._retreiveFeedbackURL(config);
+        this._retrieveFeedbackURL(config);
         this._retrieveRegisterURL(config);
         return getDocsBaseUrl(config, this.restApiService);
       })
@@ -325,7 +325,7 @@
     });
   }
 
-  _retreiveFeedbackURL(config: ServerInfo) {
+  _retrieveFeedbackURL(config: ServerInfo) {
     if (config.gerrit?.report_bug_url) {
       this._feedbackURL = config.gerrit.report_bug_url;
     }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 7430cc0..d9c43d6 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -488,7 +488,7 @@
         report_bug_url: url,
       },
     };
-    element._retreiveFeedbackURL(config);
+    element._retrieveFeedbackURL(config);
     await flush();
 
     assert.equal(element._feedbackURL, url);
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index f2ee838..632ce4c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -249,7 +249,7 @@
 
 export interface GenerateUrlChangeViewParameters {
   view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
   changeNum: NumericChangeId;
   project: RepoName;
   patchNum?: PatchSetNum;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 3a76112..676ef7b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -308,15 +308,6 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly changeService = appContext.changeService;
-
-  constructor() {
-    super();
-    // TODO: This is just an artificical dependdency such that the service is
-    // instantiated and its observables subscribed. Remove this later.
-    this.changeService.dontDoAnything();
-  }
-
   start() {
     if (!this._app) {
       return;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 43f8a2b..97d5271 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -93,6 +93,7 @@
   'onlyextensions:',
   'owner:',
   'ownerin:',
+  'parentof:',
   'parentproject:',
   'project:',
   'projects:',
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 468bb97..e381213 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -100,7 +100,7 @@
 
   private refitOverlay?: () => void;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   /**
    * Given robot comment CustomEvent object, fetch diffs associated
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 19f43c2..b60a585 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -611,7 +611,7 @@
 
   private readonly flagsService = appContext.flagsService;
 
-  private _isPortingCommentsExperimentEnabled = false;
+  private isPortingCommentsExperimentEnabled = false;
 
   /** @override */
   created() {
@@ -620,7 +620,7 @@
 
   constructor() {
     super();
-    this._isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
+    this.isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
       KnownExperimentId.PORTING_COMMENTS
     );
   }
@@ -636,10 +636,10 @@
       this.restApiService.getDiffComments(changeNum),
       this.restApiService.getDiffRobotComments(changeNum),
       this.restApiService.getDiffDrafts(changeNum),
-      this._isPortingCommentsExperimentEnabled
+      this.isPortingCommentsExperimentEnabled
         ? this.restApiService.getPortedComments(changeNum, revision)
         : Promise.resolve({}),
-      this._isPortingCommentsExperimentEnabled
+      this.isPortingCommentsExperimentEnabled
         ? this.restApiService.getPortedDrafts(changeNum, revision)
         : Promise.resolve({}),
     ];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index da1b928..4943298 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -33,7 +33,7 @@
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * For example '𐀏'.length is 2. An occurrence of such a code point is called a
  * surrogate pair.
  *
  * This regex segments a string along tabs ('\t') and surrogate pairs, since
@@ -80,7 +80,7 @@
 
   readonly groups: GrDiffGroup[];
 
-  private _blameInfo: BlameInfo[] | null;
+  private blameInfo: BlameInfo[] | null;
 
   private readonly _layerUpdateListener: (
     start: LineNumber,
@@ -104,7 +104,7 @@
     this._prefs = prefs;
     this._outputEl = outputEl;
     this.groups = [];
-    this._blameInfo = null;
+    this.blameInfo = null;
 
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       throw Error('Invalid tab size from preferences.');
@@ -765,7 +765,7 @@
    * re-render its blame cell content.
    */
   setBlame(blame: BlameInfo[] | null) {
-    this._blameInfo = blame;
+    this.blameInfo = blame;
     if (!blame) return;
 
     // TODO(wyatta): make this loop asynchronous.
@@ -890,11 +890,11 @@
    * @return The commit information.
    */
   _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this._blameInfo) {
+    if (!this.blameInfo) {
       return null;
     }
 
-    for (const blameCommit of this._blameInfo) {
+    for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
           return blameCommit;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 5ac37dc..cc3be07 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -61,7 +61,7 @@
     return htmlTemplate;
   }
 
-  private _preventAutoScrollOnManualScroll = false;
+  private preventAutoScrollOnManualScroll = false;
 
   private lastDisplayedNavigateToNextFileToast: number | null = null;
 
@@ -338,10 +338,10 @@
   }
 
   private _boundHandleWindowScroll = () => {
-    if (this._preventAutoScrollOnManualScroll) {
+    if (this.preventAutoScrollOnManualScroll) {
       this._scrollMode = ScrollMode.NEVER;
       this._focusOnMove = false;
-      this._preventAutoScrollOnManualScroll = false;
+      this.preventAutoScrollOnManualScroll = false;
     }
   };
 
@@ -360,14 +360,14 @@
   };
 
   private _boundHandleDiffRenderStart = () => {
-    this._preventAutoScrollOnManualScroll = true;
+    this.preventAutoScrollOnManualScroll = true;
   };
 
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
     this._focusOnMove = true;
-    this._preventAutoScrollOnManualScroll = false;
+    this.preventAutoScrollOnManualScroll = false;
   };
 
   private _boundHandleDiffLineSelected = (event: Event) => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index ae1e438..47b4a1f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -79,6 +79,7 @@
   fireEvent,
 } from '../../../utils/event-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -324,8 +325,8 @@
     return getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        if (!this.path) throw new Error('Missing required "path" property.');
-        if (!this.changeNum) throw new Error('Missing required "changeNum".');
+        assertIsDefined(this.path, 'path');
+        assertIsDefined(this.changeNum, 'changeNum');
         this._layers = this._getLayers(this.path, this.changeNum);
         this._coverageRanges = [];
         // We kick off fetching the data here, but we don't return the promise,
@@ -341,8 +342,8 @@
    */
   async reload(shouldReportMetric?: boolean) {
     this.clear();
-    if (!this.path) throw new Error('Missing required "path" property.');
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
@@ -420,10 +421,10 @@
   }
 
   _getCoverageData() {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.change) throw new Error('Missing required "change" prop.');
-    if (!this.path) throw new Error('Missing required "path" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchRange, 'patchRange');
     const changeNum = this.changeNum;
     const change = this.change;
     const path = this.path;
@@ -442,7 +443,7 @@
           if (!provider) return;
           provider(changeNum, path, basePatchNum, patchNum, change)
             .then(coverageRanges => {
-              if (!this.patchRange) throw new Error('Missing "patchRange".');
+              assertIsDefined(this.patchRange, 'patchRange');
               if (
                 !coverageRanges ||
                 changeNum !== this.changeNum ||
@@ -532,9 +533,9 @@
    * Load and display blame information for the base of the diff.
    */
   loadBlame(): Promise<BlameInfo[]> {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
-    if (!this.path) throw new Error('Missing required "path" property.');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.path, 'path');
     return this.restApiService
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
@@ -564,8 +565,8 @@
     this.$.diff.clearDiffContent();
   }
 
-  expandAllContext() {
-    this.$.diff.expandAllContext();
+  toggleAllContext() {
+    this.$.diff.toggleAllContext();
   }
 
   _getLoggedIn() {
@@ -599,9 +600,9 @@
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
-      if (!this.changeNum) throw new Error('Missing required "changeNum".');
-      if (!this.patchRange) throw new Error('Missing required "patchRange".');
-      if (!this.path) throw new Error('Missing required "path" property.');
+      assertIsDefined(this.changeNum, 'changeNum');
+      assertIsDefined(this.patchRange, 'patchRange');
+      assertIsDefined(this.path, 'path');
       this.restApiService
         .getDiff(
           this.changeNum,
@@ -669,7 +670,7 @@
 
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.patchRange, 'patchRange');
     if (this.patchRange.basePatchNum === 'PARENT') {
       this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
@@ -726,8 +727,8 @@
   }
 
   _getImages(diff: DiffInfo) {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
     return this.restApiService.getImagesForDiff(
       this.changeNum,
       diff,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 99486ad..01c78f0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -796,9 +796,9 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  test('delegates expandAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'expandAllContext');
-    element.expandAllContext();
+  test('delegates toggleAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
+    element.toggleAllContext();
     assert.isTrue(stub.calledOnce);
     assert.equal(stub.lastCall.args.length, 0);
   });
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
new file mode 100644
index 0000000..42268e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,304 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+
+import {Dimensions, fitToFrame, Point, Rect} from './util';
+
+/**
+ * Displays a scaled-down version of an image with a draggable frame for
+ * choosing a portion of the image to be magnified by other components.
+ *
+ * Slotted content can be arbitrary elements, but should be limited to images or
+ * stacks of image-like elements (e.g. for overlays) with limited interactivity,
+ * to prevent confusion, as the component only captures a limited set of events.
+ * Slotted content is scaled to fit the bounds of the component, with
+ * letterboxing if aspect ratios differ. For slotted content smaller than the
+ * component, it will cap the scale at 1x and also apply letterboxing.
+ */
+@customElement('gr-overview-image')
+export class GrOverviewImage extends LitElement {
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected contentStyle: StyleInfo = {};
+
+  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+
+  @internalProperty() protected frameStyle: StyleInfo = {};
+
+  @internalProperty() protected overlayStyle: StyleInfo = {};
+
+  @internalProperty() protected dragging = false;
+
+  @query('.content-box') protected contentBox!: HTMLDivElement;
+
+  @query('.content') protected content!: HTMLDivElement;
+
+  @query('.content-transform') protected contentTransform!: HTMLDivElement;
+
+  @query('.frame') protected frame!: HTMLDivElement;
+
+  private contentBounds: Dimensions = {width: 0, height: 0};
+
+  private imageBounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  // When grabbing the frame to drag it around, this stores the offset of the
+  // cursor from the center of the frame at the start of the drag.
+  private grabOffset: Point = {x: 0, y: 0};
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.contentBox) {
+          this.contentBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        if (entry.target === this.contentTransform) {
+          this.imageBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        this.updateScale();
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      --overview-image-background-color: #000;
+      --overview-image-frame-color: #f00;
+      display: flex;
+    }
+    * {
+      box-sizing: border-box;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    .content-box {
+      border: 1px solid var(--overview-image-background-color);
+      background-color: var(--overview-iamge-background-color);
+      width: 100%;
+      position: relative;
+    }
+    .content {
+      position: absolute;
+      cursor: pointer;
+    }
+    .content-transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+    .frame {
+      border: 1px solid var(--overview-image-frame-color);
+      position: absolute;
+      will-change: transform;
+    }
+    .overlay {
+      position: absolute;
+      z-index: 10000;
+      cursor: grabbing;
+    }
+  `;
+
+  render() {
+    return html`
+      <div class="content-box">
+        <div
+          class="content"
+          style="${styleMap({
+            ...this.contentStyle,
+          })}"
+          @mousemove="${this.maybeDragFrame}"
+          @mousedown=${this.clickOverview}
+          @mouseup="${this.releaseFrame}"
+        >
+          <div
+            class="content-transform"
+            style="${styleMap(this.contentTransformStyle)}"
+          >
+            <slot></slot>
+          </div>
+          <div
+            class="frame"
+            style="${styleMap({
+              ...this.frameStyle,
+              cursor: this.dragging ? 'grabbing' : 'grab',
+            })}"
+            @mousedown="${this.grabFrame}"
+          ></div>
+        </div>
+        <div
+          class="overlay"
+          style="${styleMap({
+            ...this.overlayStyle,
+            display: this.dragging ? 'block' : 'none',
+          })}"
+          @mousemove="${this.overlayMouseMove}"
+          @mouseleave="${this.releaseFrame}"
+          @mouseup="${this.releaseFrame}"
+        ></div>
+      </div>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    event.preventDefault();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.content.getBoundingClientRect();
+    this.notifyNewCenter({
+      x: (event.clientX - rect.left) / this.scale,
+      y: (event.clientY - rect.top) / this.scale,
+    });
+  }
+
+  grabFrame(event: MouseEvent) {
+    event.preventDefault();
+    // Do not bubble up into clickOverview().
+    event.stopPropagation();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.frame.getBoundingClientRect();
+    const frameCenterX = rect.x + rect.width / 2;
+    const frameCenterY = rect.y + rect.height / 2;
+    this.grabOffset = {
+      x: event.clientX - frameCenterX,
+      y: event.clientY - frameCenterY,
+    };
+  }
+
+  maybeDragFrame(event: MouseEvent) {
+    event.preventDefault();
+    if (!this.dragging) return;
+    const rect = this.content.getBoundingClientRect();
+    const center = {
+      x: (event.clientX - rect.left - this.grabOffset.x) / this.scale,
+      y: (event.clientY - rect.top - this.grabOffset.y) / this.scale,
+    };
+    this.notifyNewCenter(center);
+  }
+
+  releaseFrame(event: MouseEvent) {
+    event.preventDefault();
+    this.dragging = false;
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  overlayMouseMove(event: MouseEvent) {
+    event.preventDefault();
+    this.maybeDragFrame(event);
+  }
+
+  private updateScale() {
+    const fitted = fitToFrame(this.imageBounds, this.contentBounds);
+    this.scale = fitted.scale;
+
+    this.contentStyle = {
+      ...this.contentStyle,
+      top: `${fitted.top}px`,
+      left: `${fitted.left}px`,
+      width: `${fitted.width}px`,
+      height: `${fitted.height}px`,
+    };
+
+    this.contentTransformStyle = {
+      transform: `scale(${this.scale})`,
+    };
+
+    this.updateFrameStyle();
+  }
+
+  private updateFrameStyle() {
+    const x = this.frameRect.origin.x * this.scale;
+    const y = this.frameRect.origin.y * this.scale;
+    const width = this.frameRect.dimensions.width * this.scale;
+    const height = this.frameRect.dimensions.height * this.scale;
+    this.frameStyle = {
+      ...this.frameStyle,
+      transform: `translate(${x}px, ${y}px)`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private updateOverlaySize() {
+    const rect = this.contentBox.getBoundingClientRect();
+    // Create a whole-page overlay to capture mouse events, so that the drag
+    // interaction continues until the user releases the mouse button. Since
+    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
+    // to prevent the overlay from extending offscreen under any existing
+    // scrollbar and causing the scrollbar for the other dimension to show up
+    // unnecessarily.
+    const width = window.innerWidth - 20;
+    const height = window.innerHeight - 20;
+    this.overlayStyle = {
+      ...this.overlayStyle,
+      top: `-${rect.top + 1}px`,
+      left: `-${rect.left + 1}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private notifyNewCenter(center: Point) {
+    this.dispatchEvent(
+      new CustomEvent('center-updated', {
+        detail: {...center},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overview-image': GrOverviewImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..a14a9cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,95 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {Rect} from './util';
+
+/**
+ * Displays its slotted content at a given scale, centered over a given point,
+ * while ensuring the content always fills the container. The content does not
+ * have to be a single image, it can be arbitrary HTML. To prevent user
+ * confusion, it should ideally be image-like, i.e. have limited or no
+ * interactivity, as the component does not prevent events or focus from
+ * reaching the slotted content.
+ */
+@customElement('gr-zoomed-image')
+export class GrZoomedImage extends LitElement {
+  @property({type: Number}) scale = 1;
+
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected imageStyles: StyleInfo = {};
+
+  static styles = css`
+    :host {
+      display: block;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    #clip {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    #transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+  `;
+
+  render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style="${styleMap(this.imageStyles)}">
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
+      this.updateImageStyles();
+    }
+  }
+
+  private updateImageStyles() {
+    const {x, y} = this.frameRect.origin;
+    this.imageStyles = {
+      'image-rendering': this.scale >= 1 ? 'pixelated' : 'auto',
+      transform: `translate(${-x}px, ${-y}px) scale(${this.scale})`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zoomed-image': GrZoomedImage;
+  }
+}
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
new file mode 100644
index 0000000..b42eea9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @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.
+ */
+
+export interface Point {
+  x: number;
+  y: number;
+}
+
+export interface Dimensions {
+  width: number;
+  height: number;
+}
+
+export interface Rect {
+  origin: Point;
+  dimensions: Dimensions;
+}
+
+export interface FittedContent {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+  scale: number;
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(value, max));
+}
+
+/**
+ * Fits content of the given dimensions into the given frame, maintaining the
+ * aspect ratio of the content and applying letterboxing / pillarboxing as
+ * needed.
+ */
+export function fitToFrame(
+  content: Dimensions,
+  frame: Dimensions
+): FittedContent {
+  const contentAspectRatio = content.width / content.height;
+  const frameAspectRatio = frame.width / frame.height;
+  // If the content is wider than the frame, it will be letterboxed, otherwise
+  // it will be pillarboxed. When letterboxed, content and frame width will
+  // match exactly, when pillarboxed, content and frame height will match
+  // exactly.
+  const isLetterboxed = contentAspectRatio > frameAspectRatio;
+  let width: number;
+  let height: number;
+  if (isLetterboxed) {
+    width = Math.min(frame.width, content.width);
+    height = content.height * (width / content.width);
+  } else {
+    height = Math.min(frame.height, content.height);
+    width = content.width * (height / content.height);
+  }
+  const top = (frame.height - height) / 2;
+  const left = (frame.width - width) / 2;
+  const scale = width / content.width;
+  return {top, left, width, height, scale};
+}
+
+function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
+  const x =
+    part.dimensions.width <= bounds.width
+      ? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
+      : (bounds.width - part.dimensions.width) / 2;
+  const y =
+    part.dimensions.height <= bounds.height
+      ? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
+      : (bounds.height - part.dimensions.height) / 2;
+  return {origin: {x, y}, dimensions: part.dimensions};
+}
+
+/**
+ * Maintains a given frame inside given bounds, adjusting requested positions
+ * for the frame as needed. This supports the non-destructive application of a
+ * scaling factor, so that e.g. the magnification of an image can be changed
+ * easily while keeping the frame centered over the same spot. Changing bounds
+ * or frame size also keeps the frame position when possible.
+ */
+export class FrameConstrainer {
+  private center: Point = {x: 0, y: 0};
+
+  private frameSize: Dimensions = {width: 0, height: 0};
+
+  private bounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  private unscaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  private scaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  getCenter(): Point {
+    return {...this.center};
+  }
+
+  /**
+   * Returns the frame at its original size, positioned within the given bounds
+   * at the given scale; its origin will be in scaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 30x20, centered over (100, 50), within bounds 200x100.
+   *
+   * Useful for positioning a viewport of fixed size over a magnified image.
+   */
+  getUnscaledFrame(): Rect {
+    return {
+      origin: {...this.unscaledFrame.origin},
+      dimensions: {...this.unscaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Returns the scaled down frame–a scale of 2 will result in frame dimensions
+   * being halved—position within the given bounds at 1x scale; its origin will
+   * be in unscaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 15x10, centered over (50, 25), within bounds 100x50.
+   *
+   * Useful for highlighting the magnified portion of an image as determined by
+   * getUnscaledFrame() in an overview image of fixed size.
+   */
+  getScaledFrame(): Rect {
+    return {
+      origin: {...this.scaledFrame.origin},
+      dimensions: {...this.scaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Requests the frame to be centered over the given point, in unscaled bounds
+   * coordinates. This will keep the frame within the given bounds, also when
+   * requesting a center point fully outside the given bounds.
+   */
+  requestCenter(center: Point) {
+    this.center = {...center};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the frame size, while keeping the frame within the given bounds, and
+   * maintaining the current center if possible.
+   */
+  setFrameSize(frameSize: Dimensions) {
+    if (frameSize.width <= 0 || frameSize.height <= 0) return;
+    this.frameSize = {...frameSize};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the bounds, while keeping the frame within them, and maintaining the
+   * current center if possible.
+   */
+  setBounds(bounds: Dimensions) {
+    if (bounds.width <= 0 || bounds.height <= 0) return;
+    this.bounds = {...bounds};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the applied scale, while keeping the frame within the given bounds,
+   * and maintaining the current center if possible (both relevant moving from
+   * a larger scale to a smaller scale).
+   */
+  setScale(scale: number) {
+    if (!scale || scale <= 0) return;
+    this.scale = scale;
+
+    this.ensureFrameInBounds();
+  }
+
+  private ensureFrameInBounds() {
+    const scaledCenter = {
+      x: this.center.x * this.scale,
+      y: this.center.y * this.scale,
+    };
+    const scaledBounds = {
+      width: this.bounds.width * this.scale,
+      height: this.bounds.height * this.scale,
+    };
+    const scaledFrameSize = {
+      width: this.frameSize.width / this.scale,
+      height: this.frameSize.height / this.scale,
+    };
+
+    const requestedUnscaledFrame = {
+      origin: {
+        x: scaledCenter.x - this.frameSize.width / 2,
+        y: scaledCenter.y - this.frameSize.height / 2,
+      },
+      dimensions: this.frameSize,
+    };
+    const requestedScaledFrame = {
+      origin: {
+        x: this.center.x - scaledFrameSize.width / 2,
+        y: this.center.y - scaledFrameSize.height / 2,
+      },
+      dimensions: scaledFrameSize,
+    };
+
+    this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
+    this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
+
+    this.center = {
+      x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
+      y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
+    };
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
new file mode 100644
index 0000000..80cfa36
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
@@ -0,0 +1,171 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {FrameConstrainer} from './util.js';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index cfe2cfe..60f2853 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -60,17 +60,17 @@
       this.restApiService.savePreferences({diff_view: newMode});
     }
     this.mode = newMode;
-    let annoucement;
+    let announcement;
     if (this.isUnifiedSelected(newMode)) {
-      annoucement = 'Changed diff view to unified';
+      announcement = 'Changed diff view to unified';
     } else if (this.isSideBySideSelected(newMode)) {
-      annoucement = 'Changed diff view to side by side';
+      announcement = 'Changed diff view to side by side';
     }
-    if (annoucement) {
+    if (announcement) {
       this.fire(
         'iron-announce',
         {
-          text: annoucement,
+          text: announcement,
         },
         {bubbles: true}
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 1550108..2c4b8f6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -97,6 +97,7 @@
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
+import {assertIsDefined} from '../../../utils/common-util';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -291,7 +292,7 @@
       [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
       [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
       [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
       [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
       [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
       [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
@@ -370,7 +371,7 @@
   }
 
   _getChangeEdit() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
     return this.restApiService.getChangeEdit(this._changeNum);
   }
 
@@ -980,7 +981,7 @@
         leftSide = !!this.params.leftSide;
       }
     }
-    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    assertIsDefined(this._patchRange, '_patchRange');
     this._initLineOfInterestAndCursor(leftSide);
 
     if (this.params?.commentId) {
@@ -1052,10 +1053,10 @@
         this._initPatchRange();
         this._initCommitRange();
 
-        if (!this._path) throw new Error('path must be defined');
+        assertIsDefined(this._path, '_path');
         if (!this._changeComments)
           throw new Error('change comments must be defined');
-        if (!this._patchRange) throw new Error('patch range must be defined');
+        assertIsDefined(this._patchRange, '_patchRange');
 
         // TODO(dhruvsri): check if basePath should be set here
         this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
@@ -1082,9 +1083,9 @@
         if (!this._diff) throw new Error('Missing this._diff');
         const fileUnchanged = this._isFileUnchanged(this._diff);
         if (fileUnchanged && value.commentLink) {
-          if (!this._change) throw new Error('Missing this._change');
-          if (!this._path) throw new Error('Missing this._path');
-          if (!this._patchRange) throw new Error('Missing this._patchRange');
+          assertIsDefined(this._change, '_change');
+          assertIsDefined(this._path, '_path');
+          assertIsDefined(this._patchRange, '_patchRange');
 
           if (this._patchRange.basePatchNum === ParentPatchSetNum) {
             // file is unchanged between Base vs X
@@ -1464,9 +1465,9 @@
   ) {
     let patchNum = patchRange.patchNum;
 
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
 
-    if (isBase && !comparedAgainsParent) {
+    if (isBase && !comparedAgainstParent) {
       patchNum = patchRange.basePatchNum;
     }
 
@@ -1474,7 +1475,7 @@
       changeBaseURL(project, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
 
-    if (isBase && comparedAgainsParent) {
+    if (isBase && comparedAgainstParent) {
       url += '?parent=1';
     }
 
@@ -1493,7 +1494,7 @@
   }
 
   _loadComments(patchSet?: PatchSetNum) {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
     return this.$.commentAPI
       .loadAll(this._changeNum, patchSet)
       .then(comments => {
@@ -1529,7 +1530,7 @@
   }
 
   _getDiffDrafts() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
     return this.restApiService.getDiffDrafts(this._changeNum);
   }
@@ -1744,10 +1745,10 @@
     return '';
   }
 
-  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+  _handleToggleAllDiffContext(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
-    this.$.diffHost.expandAllContext();
+    this.$.diffHost.toggleAllContext();
   }
 
   _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 46a670d..b5473a3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -63,7 +63,7 @@
       kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
       kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
       kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      kb.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
       kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
       kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
       kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
@@ -508,11 +508,11 @@
       assert.isTrue(diffChangeStub.called);
     });
 
-    test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
+    test('shift+x shortcut toggles all diff context', () => {
+      const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
       MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
       flush();
-      assert.isTrue(expandStub.called);
+      assert.isTrue(toggleStub.called);
     });
 
     test('diff against base', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 5edd353..0ca929a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -40,7 +40,7 @@
 }
 
 export function isLongCommentRange(range: CommentRange): boolean {
-  return range.end_line - range.start_line > 5;
+  return range.end_line - range.start_line > 10;
 }
 
 export function getLineNumber(lineEl?: Element | null): LineNumber | null {
@@ -88,7 +88,7 @@
 // TODO: This type should be exposed to gr-diff clients in a separate type file.
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
-// TODO: Also document the required HTML attritbutes that thread elements must
+// TODO: Also document the required HTML attributes that thread elements must
 // have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 3fb1a50..6974a76 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -55,7 +55,11 @@
   PolymerDomWrapper,
 } from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
@@ -71,13 +75,13 @@
   RenderPreferences,
 } from '../../../api/diff';
 import {isSafari} from '../../../utils/dom-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
 const LARGE_DIFF_THRESHOLD_LINES = 10000;
 const FULL_CONTEXT = -1;
-const LIMITED_CONTEXT = 10;
 
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
@@ -626,7 +630,7 @@
     const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
-    if (!this.path) throw new Error('must have a path to create comments');
+    assertIsDefined(this.path, 'path');
     this.dispatchEvent(
       new CustomEvent<CreateCommentEventDetail>('create-comment', {
         bubbles: true,
@@ -968,8 +972,12 @@
     this._debounceRenderDiffTable();
   }
 
-  _handleLimitedBypass() {
-    this._safetyBypass = LIMITED_CONTEXT;
+  _collapseContext() {
+    // Uses the default context amount if the preference is for the entire file.
+    this._safetyBypass =
+      this.prefs?.context && this.prefs.context >= 0
+        ? null
+        : createDefaultDiffPrefs().context;
     this._debounceRenderDiffTable();
   }
 
@@ -981,8 +989,15 @@
     return errorMessage ? 'showError' : '';
   }
 
-  expandAllContext() {
-    this._handleFullBypass();
+  toggleAllContext() {
+    if (!this.prefs) {
+      return;
+    }
+    if (this._getBypassPrefs(this.prefs).context < 0) {
+      this._collapseContext();
+    } else {
+      this._handleFullBypass();
+    }
   }
 
   _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 4b477cd..4d0e566 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -623,7 +623,7 @@
       Prevented render because "Whole file" is enabled and this diff is very
       large (about [[_diffLength]] lines).
     </p>
-    <gr-button on-click="_handleLimitedBypass">
+    <gr-button on-click="_collapseContext">
       Render with limited context
     </gr-button>
     <gr-button on-click="_handleFullBypass">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 2215d5b..49eac72 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -558,7 +558,7 @@
     test('adds long range comment chip', async () => {
       const range = {
         start_line: 1,
-        end_line: 7,
+        end_line: 12,
         start_character: 0,
         end_character: 0,
       };
@@ -572,7 +572,7 @@
         a: [],
         b: [],
       }, {
-        ab: Array(8).fill('text'),
+        ab: Array(13).fill('text'),
       }];
       setupSampleDiff({content});
 
@@ -586,7 +586,7 @@
     test('no duplicate range chip for same thread', async () => {
       const range = {
         start_line: 1,
-        end_line: 7,
+        end_line: 12,
         start_character: 0,
         end_character: 0,
       };
@@ -604,7 +604,7 @@
         a: [],
         b: [],
       }, {
-        ab: Array(8).fill('text'),
+        ab: Array(13).fill('text'),
       }];
       setupSampleDiff({content});
 
@@ -794,6 +794,43 @@
       element.addEventListener('render', rendered);
       element._renderDiffTable();
     });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element._safetyBypass, -1);
+      assert.equal(renderStub.firstCall.lastArg.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element._safetyBypass = -1;
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element._safetyBypass);
+      assert.equal(renderStub.firstCall.lastArg.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element._safetyBypass, 10);
+      assert.equal(renderStub.firstCall.lastArg.context, 10);
+    });
   });
 
   suite('blame', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index 52465b3..5ab8449 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -35,7 +35,6 @@
         text-transform: none;
         font-family: var(--font-family);
       }
-      --trigger-hover-color: rgba(0, 0, 0, 0.6);
     }
     @media screen and (max-width: 50em) {
       .filesWeblinks {
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
index fad717d..8279ab1 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -72,7 +72,7 @@
         side: 'right',
         range: {
           end_character: 1,
-          end_line: 70,
+          end_line: 71,
           start_character: 1,
           start_line: 60,
         },
@@ -314,10 +314,10 @@
     assert.equal(element._rangesMap.left[39][0].start, 0);
     assert.equal(element._rangesMap.left[39][0].end, 9);
 
-    // The right has four ranged comments: 10-12, 55-55, 60-70, 100-100
+    // The right has four ranged comments: 10-12, 55-55, 60-71, 100-100
     const rightKeys = [];
     for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-    for (let i = 60; i <= 70; i++) { rightKeys.push('' + i); }
+    for (let i = 60; i <= 71; i++) { rightKeys.push('' + i); }
     rightKeys.push('55', '100');
     assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
         rightKeys.sort());
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index f38824f..ee52ab6 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -23,7 +23,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
 import {fireEvent} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -57,11 +56,6 @@
   @property({type: Boolean})
   positionBelow = false;
 
-  @property({type: Boolean})
-  disableKeyboardShortcuts = false;
-
-  private restApiService = appContext.restApiService;
-
   /** @override */
   created() {
     super.created();
@@ -70,18 +64,6 @@
     this.addEventListener('mousedown', e => this._handleMouseDown(e));
   }
 
-  attached() {
-    this.restApiService.getPreferences().then(prefs => {
-      if (prefs?.disable_keyboard_shortcuts) {
-        this.disableKeyboardShortcuts = true;
-      }
-    });
-  }
-
-  _getTextForTooltip(disableKeyboardShortcuts: boolean) {
-    return disableKeyboardShortcuts ? 'Click to comment' : 'Press c to comment';
-  }
-
   placeAbove(el: Text | Element | Range) {
     flush();
     const rect = this._getTargetBoundingRect(el);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
index d293967..24d63b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
@@ -27,7 +27,7 @@
   </style>
   <gr-tooltip
     id="tooltip"
-    text="[[_getTextForTooltip(disableKeyboardShortcuts)]]"
+    text="Press c to comment"
     position-below="[[positionBelow]]"
   ></gr-tooltip>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index da29b85..1150674 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -492,7 +492,7 @@
    * with code it shouldn't AND to avoid executing regexes as much as
    * possible.
    * * These tests should document the issue clearly enough that the test can
-   * be condidently removed when the issue is solved in HLJS.
+   * be confidently removed when the issue is solved in HLJS.
    * * These tests should rewrite the line of code to have the same number of
    * characters. This method rewrites the string that gets parsed, but NOT
    * the string that gets displayed and highlighted. Thus, the positions
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index b36edd4..bc153ee 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -277,7 +277,8 @@
     return this.restApiService
       .queryChangeFiles(this.change._number, this.patchNum, input)
       .then(res => {
-        if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+        if (!res)
+          throw new Error('Failed to retrieve files. Response not set.');
         return res.map(file => {
           return {name: file};
         });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
index be6ffc4..bbf4790 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -50,14 +50,14 @@
 
   suite('edit button CUJ', () => {
     let navStubs;
-    let openAutoCcmplete;
+    let openAutoComplete;
 
     setup(() => {
       navStubs = [
         sinon.stub(GerritNav, 'getEditUrlForDiff'),
         sinon.stub(GerritNav, 'navigateToRelativeUrl'),
       ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
 
     test('_isValidPath', () => {
@@ -77,9 +77,9 @@
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
         // setup focus. flush and/or flushAsynchronousOperations don't help
-        openAutoCcmplete._focused = true;
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete._focused = true;
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
@@ -95,8 +95,8 @@
       MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
             .querySelector('gr-button'));
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index f6f4395..1e08a5c 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -45,6 +45,7 @@
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -199,7 +200,7 @@
   }
 
   _handlePathChanged(e: CustomEvent<string>) {
-    // TODO(TS) could be cleand up, it was added for type requirements
+    // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
       return Promise.reject(new Error('changeNum or path undefined'));
     }
@@ -326,7 +327,7 @@
   }
 
   _handlePublishTap() {
-    if (!this._changeNum) throw new Error('missing changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
     const changeNum = this._changeNum;
     this._saveEdit().then(() => {
@@ -347,7 +348,7 @@
           handleError
         )
         .then(() => {
-          if (!this._change) throw new Error('missing change');
+          assertIsDefined(this._change, '_change');
           GerritNav.navigateToChange(this._change);
         });
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index cdc9f98..ec3bd0f 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -206,7 +206,7 @@
 
   private reporting = appContext.reportingService;
 
-  private restApiService = appContext.restApiService;
+  private readonly restApiService = appContext.restApiService;
 
   keyboardShortcuts() {
     return {
@@ -392,7 +392,7 @@
     }
     this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
     this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
     this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
     this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
     this.bindShortcut(
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index ea7bdc3..7ea3be3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -20,7 +20,7 @@
 import {GrAttributeHelper} from './gr-attribute-helper.js';
 
 Polymer({
-  is: 'gr-attrubute-helper-some-element',
+  is: 'gr-attribute-helper-some-element',
   properties: {
     fooBar: {
       type: Object,
@@ -29,7 +29,7 @@
   },
 });
 
-const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
 suite('gr-attribute-helper tests', () => {
   let element;
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index 3a61bce..a03c5dc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -19,24 +19,24 @@
 import {HookApi} from '../../../api/hook';
 
 export class GrChangeMetadataApi implements ChangeMetadataPluginApi {
-  private _hook: HookApi | null;
+  private hook: HookApi | null;
 
   public plugin: PluginApi;
 
   constructor(plugin: PluginApi) {
     this.plugin = plugin;
-    this._hook = null;
+    this.hook = null;
   }
 
   _createHook() {
-    this._hook = this.plugin.hook('change-metadata-item');
+    this.hook = this.plugin.hook('change-metadata-item');
   }
 
   onLabelsChanged(callback: (value: unknown) => void) {
-    if (!this._hook) {
+    if (!this.hook) {
       this._createHook();
     }
-    this._hook!.onAttached((element: Element) =>
+    this.hook!.onAttached((element: Element) =>
       this.plugin.attributeHelper(element).bind('labels', callback)
     );
     return this;
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index d2568ad..3e8f0a4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -19,13 +19,13 @@
 import {HookApi, HookCallback} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private _hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook>;
 
-  private _plugin: PluginApi;
+  private plugin: PluginApi;
 
   constructor(plugin: PluginApi) {
-    this._plugin = plugin;
-    this._hooks = {};
+    this.plugin = plugin;
+    this.hooks = {};
   }
 
   _getHookName(endpointName: string, moduleName?: string) {
@@ -36,37 +36,36 @@
       // TODO: this still can not prevent if plugin has invalid char
       // other than uppercase, but is the first step
       // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
-      const pluginName: string =
-        this._plugin.getPluginName() || 'unknown_plugin';
+      const pluginName: string = this.plugin.getPluginName() || 'unknownplugin';
       return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
     }
   }
 
   getDomHook(endpointName: string, moduleName?: string) {
     const hookName = this._getHookName(endpointName, moduleName);
-    if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName, moduleName);
+    if (!this.hooks[hookName]) {
+      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
     }
-    return this._hooks[hookName];
+    return this.hooks[hookName];
   }
 }
 
 export class GrDomHook implements HookApi {
-  private _instances: HTMLElement[] = [];
+  private instances: HTMLElement[] = [];
 
-  private _attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback[] = [];
 
-  private _detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback[] = [];
 
-  private _moduleName: string;
+  private moduleName: string;
 
-  private _lastAttachedPromise: Promise<HTMLElement> | null = null;
+  private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
   constructor(hookName: string, moduleName?: string) {
     if (moduleName) {
-      this._moduleName = moduleName;
+      this.moduleName = moduleName;
     } else {
-      this._moduleName = hookName;
+      this.moduleName = hookName;
       this._createPlaceholder(hookName);
     }
   }
@@ -89,16 +88,16 @@
   }
 
   handleInstanceDetached(instance: HTMLElement) {
-    const index = this._instances.indexOf(instance);
+    const index = this.instances.indexOf(instance);
     if (index !== -1) {
-      this._instances.splice(index, 1);
+      this.instances.splice(index, 1);
     }
-    this._detachCallbacks.forEach(callback => callback(instance));
+    this.detachCallbacks.forEach(callback => callback(instance));
   }
 
   handleInstanceAttached(instance: HTMLElement) {
-    this._instances.push(instance);
-    this._attachCallbacks.forEach(callback => callback(instance));
+    this.instances.push(instance);
+    this.attachCallbacks.forEach(callback => callback(instance));
   }
 
   /**
@@ -106,32 +105,32 @@
    * Returns a Promise, that's resolved when attachment is done.
    */
   getLastAttached(): Promise<HTMLElement> {
-    if (this._instances.length) {
-      return Promise.resolve(this._instances.slice(-1)[0]);
+    if (this.instances.length) {
+      return Promise.resolve(this.instances.slice(-1)[0]);
     }
-    if (!this._lastAttachedPromise) {
+    if (!this.lastAttachedPromise) {
       let resolve: HookCallback;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
-        this._attachCallbacks.push(resolve);
+        this.attachCallbacks.push(resolve);
       });
-      this._lastAttachedPromise = promise.then((element: HTMLElement) => {
-        this._lastAttachedPromise = null;
-        const index = this._attachCallbacks.indexOf(resolve);
+      this.lastAttachedPromise = promise.then((element: HTMLElement) => {
+        this.lastAttachedPromise = null;
+        const index = this.attachCallbacks.indexOf(resolve);
         if (index !== -1) {
-          this._attachCallbacks.splice(index, 1);
+          this.attachCallbacks.splice(index, 1);
         }
         return element;
       });
     }
-    return this._lastAttachedPromise;
+    return this.lastAttachedPromise;
   }
 
   /**
    * Get all DOM hook elements.
    */
   getAllAttached() {
-    return this._instances;
+    return this.instances;
   }
 
   /**
@@ -139,7 +138,7 @@
    * is attached.
    */
   onAttached(callback: HookCallback) {
-    this._attachCallbacks.push(callback);
+    this.attachCallbacks.push(callback);
     return this;
   }
 
@@ -149,7 +148,7 @@
    *
    */
   onDetached(callback: HookCallback) {
-    this._detachCallbacks.push(callback);
+    this.detachCallbacks.push(callback);
     return this;
   }
 
@@ -157,6 +156,6 @@
    * Name of DOM hook element that will be installed into the endpoint.
    */
   getModuleName() {
-    return this._moduleName;
+    return this.moduleName;
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index 49223b9..883f2a6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -45,7 +45,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
       assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
     });
@@ -57,7 +57,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'foo-bar my-el');
       assert.equal(hook.getModuleName(), 'my-el');
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 07d11ec..dcabc80 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -27,23 +27,23 @@
 /**
  * Plugin popup API.
  * Provides method for opening and closing popups from plugin.
- * opt_moduleName is a name of custom element that will be automatically
+ * optmoduleName is a name of custom element that will be automatically
  * inserted on popup opening.
  */
 export class GrPopupInterface implements PopupPluginApi {
-  private _openingPromise: Promise<GrPopupInterface> | null = null;
+  private openingPromise: Promise<GrPopupInterface> | null = null;
 
-  private _popup: GrPluginPopup | null = null;
+  private popup: GrPluginPopup | null = null;
 
   constructor(
     readonly plugin: PluginApi,
-    private _moduleName: string | null = null
+    private moduleName: string | null = null
   ) {}
 
   _getElement() {
     // TODO(TS): maybe consider removing this if no one is using
     // anything other than native methods on the return
-    return (dom(this._popup) as unknown) as HTMLElement;
+    return (dom(this.popup) as unknown) as HTMLElement;
   }
 
   /**
@@ -52,34 +52,34 @@
    * if it was provided with constructor.
    */
   open(): Promise<PopupPluginApi> {
-    if (!this._openingPromise) {
-      this._openingPromise = this.plugin
+    if (!this.openingPromise) {
+      this.openingPromise = this.plugin
         .hook('plugin-overlay')
         .getLastAttached()
         .then(hookEl => {
           const popup = document.createElement('gr-plugin-popup');
-          if (this._moduleName) {
+          if (this.moduleName) {
             const el = popup.appendChild(
-              document.createElement(this._moduleName) as CustomPolymerPluginEl
+              document.createElement(this.moduleName) as CustomPolymerPluginEl
             );
             el.plugin = this.plugin;
           }
-          this._popup = hookEl.appendChild(popup);
+          this.popup = hookEl.appendChild(popup);
           flush();
-          return this._popup.open().then(() => this);
+          return this.popup.open().then(() => this);
         });
     }
-    return this._openingPromise;
+    return this.openingPromise;
   }
 
   /**
    * Hides the popup.
    */
   close() {
-    if (!this._popup) {
+    if (!this.popup) {
       return;
     }
-    this._popup.close();
-    this._openingPromise = null;
+    this.popup.close();
+    this.openingPromise = null;
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
index e42ca08..0418edb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -29,7 +29,7 @@
 }
 
 export class GrRepoApi implements RepoPluginApi {
-  private _hook?: HookApi;
+  private hook?: HookApi;
 
   constructor(readonly plugin: PluginApi) {}
 
@@ -43,12 +43,12 @@
   }
 
   createCommand(title: string, callback: RepoCommandCallback) {
-    if (this._hook) {
+    if (this.hook) {
       console.warn('Already set up.');
       return this;
     }
-    this._hook = this._createHook(title);
-    this._hook.onAttached(element => {
+    this.hook = this._createHook(title);
+    this.hook.onAttached(element => {
       if (callback(element.repoName, element.config) === false) {
         element.hidden = true;
       }
@@ -57,11 +57,11 @@
   }
 
   onTap(callback: (event: Event) => boolean) {
-    if (!this._hook) {
+    if (!this.hook) {
       console.warn('Call createCommand first.');
       return this;
     }
-    this._hook.onAttached(element => {
+    this.hook.onAttached(element => {
       this.plugin.eventHelper(element).on('command-tap', callback);
     });
     return this;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index 91ca402..59203d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-m);
     }
     .agreementsUrl {
-      border: 1px solid #b0bdcc;
+      border: 1px solid var(--border-color);
       margin-bottom: var(--spacing-xl);
       margin-left: var(--spacing-xl);
       margin-right: var(--spacing-xl);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 3c00a47..809139d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -76,7 +76,6 @@
   'email_strategy',
   'diff_view',
   'publish_comments_on_push',
-  'disable_keyboard_shortcuts',
   'work_in_progress_by_default',
   'default_base_for_merges',
   'signed_off_by',
@@ -115,7 +114,6 @@
     workInProgressByDefault: HTMLInputElement;
     showSizeBarsInFileList: HTMLInputElement;
     publishCommentsOnPush: HTMLInputElement;
-    disableKeyboardShortcuts: HTMLInputElement;
     relativeDateInChangeTable: HTMLInputElement;
   };
 }
@@ -386,13 +384,6 @@
     );
   }
 
-  _handleDisableKeyboardShortcutsChanged() {
-    this.set(
-      '_localPrefs.disable_keyboard_shortcuts',
-      this.$.disableKeyboardShortcuts.checked
-    );
-  }
-
   _handleWorkInProgressByDefault() {
     this.set(
       '_localPrefs.work_in_progress_by_default',
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index b141a86..11372a1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -278,19 +278,6 @@
           </span>
         </section>
         <section>
-          <label for="disableKeyboardShortcuts" class="title"
-            >Disable all keyboard shortcuts</label
-          >
-          <span class="value">
-            <input
-              id="disableKeyboardShortcuts"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
-              on-change="_handleDisableKeyboardShortcutsChanged"
-            />
-          </span>
-        </section>
-        <section>
           <label for="insertSignedOff" class="title">
             Insert Signed-off-by Footer For Inline Edit Changes
           </label>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
index 3bb1458..4e6dd1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -58,7 +58,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
index d105c5d..e55c8f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -22,7 +22,7 @@
       display: inline-block;
       border-radius: 50%;
       background-size: cover;
-      background-color: var(--avatar-background-color, #f1f2f3);
+      background-color: var(--avatar-background-color, var(--gray-background));
     }
   </style>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 7a6ce2c..60b891e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -61,7 +61,7 @@
   tooltip = '';
 
   // Note: don't assign a value to this, since constructor is called
-  // after created, the initial value maybe overriden by this
+  // after created, the initial value maybe overridden by this
   @property({type: String})
   _initialTabindex?: string;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index d83ca49..7cf9bb1 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -35,9 +35,9 @@
   "It will not appear on dashboards unless you are CC'ed or assigned, " +
   'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP =
+export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
+  'Download the patch and run "git rebase". ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
 const PRIVATE_TOOLTIP =
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
index 770a21c..16fc664 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-change-status.js';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
 
 const basicFixture = fixtureFromElement('gr-change-status');
 
@@ -24,10 +25,6 @@
     'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
     'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
-  'Upload a new patchset after resolving all merge conflicts.';
-
 const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
     'current reviewers (or anyone with "View Private Changes" permission).';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index a9b910d..a5b7df7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -60,7 +60,7 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
-import {check, checkProperty} from '../../../utils/common-util';
+import {check, assertIsDefined} from '../../../utils/common-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -334,8 +334,8 @@
   }
 
   _getUrlForViewDiff(comments: UIComment[]) {
-    checkProperty(!!this.changeNum, 'changeNum');
-    checkProperty(!!this.projectName, 'projectName');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.projectName, 'projectName');
     check(comments.length > 0, 'comment not found');
     return GerritNav.getUrlForComment(
       this.changeNum,
@@ -633,8 +633,8 @@
   }
 
   _handleCommentDiscard(e: Event) {
-    if (!this.changeNum) throw new Error('changeNum is missing');
-    if (!this.patchNum) throw new Error('patchNum is missing');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchNum, 'patchNum');
     const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
     const comment = diffCommentEl.comment;
     const idx = this._indexOf(comment, this.comments);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 76bbd67..55408c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -23,12 +23,12 @@
       font-size: var(--font-size-normal);
       font-weight: var(--font-weight-normal);
       line-height: var(--line-height-normal);
-      /* Explicitly set the background color of the diff to be white. We
+      /* Explicitly set the background color of the diff. We
        * cannot use the diff content type ab because of the skip chunk preceding
        * it, diff processor assumes the chunk of type skip/ab can be collapsed
        * and hides our diff behind context control buttons.
        *  */
-      --dark-add-highlight-color: white;
+      --dark-add-highlight-color: var(--background-color-primary);
     }
     gr-button {
       margin-left: var(--spacing-m);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 898aff3..bf376f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -61,6 +61,7 @@
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -322,7 +323,7 @@
   }
 
   _handlePortedMessageClick() {
-    if (!this.comment) throw new Error('comment not set');
+    assertIsDefined(this.comment, 'comment');
     this.reporting.reportInteraction('navigate-to-original-comment', {
       line: this.comment.line,
       range: this.comment.range,
@@ -496,10 +497,8 @@
     // prior to it being saved.
     this.cancelDebouncer(DEBOUNCER_STORE);
 
-    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
-    if (this.changeNum === undefined) {
-      throw new Error('undefined changeNum');
-    }
+    assertIsDefined(this.comment?.path, 'comment.path');
+    assertIsDefined(this.changeNum, 'changeNum');
     this.storage.eraseDraftComment({
       changeNum: this.changeNum,
       patchNum: this._getPatchNum(),
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 2fbbd7c..119ed20 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -54,8 +54,8 @@
 export type Stop = HTMLElement | AbortStop;
 
 /**
- * Type guard and checker to check if a stop can be targetted.
- * Abort stops cannot be targetted.
+ * Type guard and checker to check if a stop can be targeted.
+ * Abort stops cannot be targeted.
  */
 export function isTargetable(stop: Stop): stop is HTMLElement {
   return !(stop instanceof AbortStop);
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index d2f003b..4c2a417 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -52,7 +52,7 @@
 
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
-  comamnds?: Command[];
+  commands?: Command[];
 
   @property({type: Boolean})
   _loggedIn = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 3fce16e..888f34f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -32,7 +32,7 @@
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 /**
- * Requred values are text and value. mobileText and triggerText will
+ * Required values are text and value. mobileText and triggerText will
  * fall back to text if not provided.
  *
  * If bottomText is not provided, nothing will display on the second
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index c744eab..2780fbe 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -120,6 +120,8 @@
 
   private readonly flagsService = appContext.flagsService;
 
+  private readonly reporting = appContext.reportingService;
+
   /** @override */
   ready() {
     super.ready();
@@ -238,6 +240,10 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'Commit message',
+      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+    });
     if (this._commitCollapsed) {
       window.scrollTo(0, 0);
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 374cc62..0f530bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -68,6 +68,11 @@
       border-radius: 0 0 4px 4px;
       border-color: var(--border-color);
       box-shadow: var(--elevation-level-1);
+      /* slightly up to cover rounded corner of the commit msg */
+      margin-top: calc(-1 * var(--spacing-xs));
+      /* To make this bar pop over editor, since editor has relative position. 
+      */
+      position: relative;
     }
     .show-all-container .show-all-button {
       margin-right: auto;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 592f7903..ddae8ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -45,6 +45,7 @@
 import {ReviewerState} from '../../../constants/constants';
 import {CURRENT} from '../../../utils/patch-set-util';
 import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
 @customElement('gr-hovercard-account')
 export class GrHovercardAccount extends GestureEventListeners(
@@ -163,7 +164,7 @@
   }
 
   _handleChangeReviewerOrCCStatus() {
-    if (!this.change) throw new Error('expected change object to be present');
+    assertIsDefined(this.change, 'change');
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
     const _accountKey = accountKey(this.account);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index e67e1f6..b8f0161 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -110,13 +110,13 @@
       @property({type: String})
       containerId = 'gr-hovercard-container';
 
-      private _hideDebouncer: Debouncer | null = null;
+      private hideDebouncer: Debouncer | null = null;
 
-      private _showDebouncer: Debouncer | null = null;
+      private showDebouncer: Debouncer | null = null;
 
-      private _isScheduledToShow?: boolean;
+      private isScheduledToShow?: boolean;
 
-      private _isScheduledToHide?: boolean;
+      private isScheduledToHide?: boolean;
 
       /** @override */
       attached() {
@@ -174,24 +174,24 @@
 
       debounceHide() {
         this.cancelShowDebouncer();
-        if (!this._isShowing || this._isScheduledToHide) return;
-        this._isScheduledToHide = true;
-        this._hideDebouncer = Debouncer.debounce(
-          this._hideDebouncer,
+        if (!this._isShowing || this.isScheduledToHide) return;
+        this.isScheduledToHide = true;
+        this.hideDebouncer = Debouncer.debounce(
+          this.hideDebouncer,
           timeOut.after(HIDE_DELAY_MS),
           () => {
             // This happens when hide immediately through click or mouse leave
             // on the hovercard
-            if (!this._isScheduledToHide) return;
+            if (!this.isScheduledToHide) return;
             this.hide();
           }
         );
       }
 
       cancelHideDebouncer() {
-        if (this._hideDebouncer) {
-          this._hideDebouncer.cancel();
-          this._isScheduledToHide = false;
+        if (this.hideDebouncer) {
+          this.hideDebouncer.cancel();
+          this.isScheduledToHide = false;
         }
       }
 
@@ -305,23 +305,23 @@
        */
       debounceShowBy(delayMs: number) {
         this.cancelHideDebouncer();
-        if (this._isShowing || this._isScheduledToShow) return;
-        this._isScheduledToShow = true;
-        this._showDebouncer = Debouncer.debounce(
-          this._showDebouncer,
+        if (this._isShowing || this.isScheduledToShow) return;
+        this.isScheduledToShow = true;
+        this.showDebouncer = Debouncer.debounce(
+          this.showDebouncer,
           timeOut.after(delayMs),
           () => {
             // This happens when the mouse leaves the target before the delay is over.
-            if (!this._isScheduledToShow) return;
+            if (!this.isScheduledToShow) return;
             this.show();
           }
         );
       }
 
       cancelShowDebouncer() {
-        if (this._showDebouncer) {
-          this._showDebouncer.cancel();
-          this._isScheduledToShow = false;
+        if (this.showDebouncer) {
+          this.showDebouncer.cancel();
+          this.isScheduledToShow = false;
         }
       }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
index 6b2e620..628b1e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -120,18 +120,18 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
-    element._showDebouncer.flush();
+    assert.isTrue(element.isScheduledToShow);
+    element.showDebouncer.flush();
     assert.isTrue(element._isShowing);
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
 
     button.dispatchEvent(new CustomEvent('mouseleave'));
 
     await leavePromise;
-    assert.isTrue(element._isScheduledToHide);
+    assert.isTrue(element.isScheduledToHide);
     assert.isTrue(element._isShowing);
-    element._hideDebouncer.flush();
-    assert.isFalse(element._isScheduledToShow);
+    element.hideDebouncer.flush();
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
@@ -152,11 +152,11 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
+    assert.isTrue(element.isScheduledToShow);
     MockInteractions.tap(button);
 
     await clickPromise;
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
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 0a3ef5b..7745da8 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -128,6 +128,8 @@
       <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
     </defs>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
index 6a4da7b..9f9ba6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -29,14 +29,13 @@
  * @param lineNumberEl The TD element of the line number to
  * apply the annotation to using annotateLineNumber.
  * @param line The line object.
- * @param path The file path (eg: /COMMIT_MSG').
+ * @param path The file path (eg: '/COMMIT_MSG').
  * @param changeNum The Gerrit change number.
- * @param patchNum The Gerrit patch number.
  */
 export class GrAnnotationActionsContext implements AnnotationContext {
-  private _contentEl: HTMLElement;
+  contentEl: HTMLElement;
 
-  private _lineNumberEl: HTMLElement;
+  lineNumberEl: HTMLElement;
 
   line: GrDiffLine;
 
@@ -53,9 +52,8 @@
     path: string,
     changeNum: string | number
   ) {
-    this._contentEl = contentEl;
-    this._lineNumberEl = lineNumberEl;
-
+    this.contentEl = contentEl;
+    this.lineNumberEl = lineNumberEl;
     this.line = line;
     this.path = path;
     this.changeNum = Number(changeNum);
@@ -80,12 +78,12 @@
     styleObject: GrStyleObject,
     side: string
   ) {
-    if (this._contentEl?.getAttribute('data-side') === side) {
+    if (this.contentEl?.getAttribute('data-side') === side) {
       GrAnnotation.annotateElement(
-        this._contentEl,
+        this.contentEl,
         offset,
         length,
-        styleObject.getClassName(this._contentEl)
+        styleObject.getClassName(this.contentEl)
       );
     }
   }
@@ -97,8 +95,8 @@
    * @param side The side of the update. ('left' or 'right')
    */
   annotateLineNumber(styleObject: GrStyleObject, side: string) {
-    if (this._lineNumberEl?.classList.contains(side)) {
-      styleObject.apply(this._lineNumberEl);
+    if (this.lineNumberEl?.classList.contains(side)) {
+      styleObject.apply(this.lineNumberEl);
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 4abc6e1..a3d038d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -21,65 +21,37 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 import {appContext} from '../../../services/app-context';
 import {
-  AddLayerFunc,
+  AnnotationCallback,
   AnnotationPluginApi,
   CoverageProvider,
-  NotifyFunc,
 } from '../../../api/annotation';
 
 export class GrAnnotationActionsInterface implements AnnotationPluginApi {
-  // Collect all annotation layers instantiated by getLayer. Will be used when
-  // notifying their listeners in the notify function.
+  /**
+   * Collect all annotation layers instantiated by createLayer. This is only
+   * used for being able to look up the appropriate layer when notify() is
+   * being called by plugins.
+   */
   private annotationLayers: AnnotationLayer[] = [];
 
-  private coverageProvider: CoverageProvider | null = null;
+  private coverageProvider?: CoverageProvider;
 
-  // Default impl is a no-op.
-  private addLayerFunc: AddLayerFunc = () => {};
+  private annotationCallback?: AnnotationCallback;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {
-    // Return this instance when there is an annotatediff event.
     plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
-  /**
-   * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange and
-   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
-   * line content or the line number.
-   *
-   * @param addLayerFunc The function
-   * that will be called when the AnnotationLayer is ready to annotate.
-   */
-  addLayer(addLayerFunc: AddLayerFunc) {
-    this.addLayerFunc = addLayerFunc;
+  setLayer(annotationCallback: AnnotationCallback) {
+    if (this.annotationCallback) {
+      console.warn('Overwriting an existing plugin annotation layer.');
+    }
+    this.annotationCallback = annotationCallback;
     return this;
   }
 
-  /**
-   * The specified function will be called with a notify function for the plugin
-   * to call when it has all required data for annotation. Optional.
-   *
-   * @param notifyFunc See doc of the notify function below to see what it does.
-   */
-  addNotifier(notifyFunc: (n: NotifyFunc) => void) {
-    notifyFunc(
-      (path: string, startRange: number, endRange: number, side: Side) =>
-        this.notify(path, startRange, endRange, side)
-    );
-    return this;
-  }
-
-  /**
-   * The specified function will be called when a gr-diff component is built,
-   * and feeds the returned coverage data into the diff. Optional.
-   *
-   * Be sure to call this only once and only from one plugin. Multiple coverage
-   * providers are not supported. A second call will just overwrite the
-   * provider of the first call.
-   */
   setCoverageProvider(
     coverageProvider: CoverageProvider
   ): GrAnnotationActionsInterface {
@@ -98,23 +70,6 @@
     return this.coverageProvider;
   }
 
-  /**
-   * Returns a checkbox HTMLElement that can be used to toggle annotations
-   * on/off. The checkbox will be initially disabled. Plugins should enable it
-   * when data is ready and should add a click handler to toggle CSS on/off.
-   *
-   * Note1: Calling this method from multiple plugins will only work for the
-   * 1st call. It will print an error message for all subsequent calls
-   * and will not invoke their onAttached functions.
-   * Note2: This method will be deprecated and eventually removed when
-   * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
-   * implemented.
-   *
-   * @param checkboxLabel Will be used as the label for the checkbox.
-   * Optional. "Enable" is used if this is not specified.
-   * @param onAttached The function that will be called
-   * when the checkbox is attached to the page.
-   */
   enableToggleCheckbox(
     checkboxLabel: string,
     onAttached: (checkboxEl: Element | null) => void
@@ -148,16 +103,6 @@
     return this;
   }
 
-  /**
-   * The notify function will call the listeners of all required annotation
-   * layers. Intended to be called by the plugin when all required data for
-   * annotation is available.
-   *
-   * @param path The file path whose listeners should be notified.
-   * @param start The line where the update starts.
-   * @param end The line where the update ends.
-   * @param side The side of the update ('left' or 'right').
-   */
   notify(path: string, start: number, end: number, side: Side) {
     for (const annotationLayer of this.annotationLayers) {
       // Notify only the annotation layer that is associated with the specified
@@ -169,24 +114,21 @@
   }
 
   /**
-   * Should be called to register annotation layers by the framework. Not
-   * intended to be called by plugins.
+   * Factory method called by Gerrit for creating a DiffLayer for each diff that
+   * is rendered.
    *
-   * Don't forget to dispose layer.
-   *
-   * @param path The file path (eg: /COMMIT_MSG').
-   * @param changeNum The Gerrit change number.
+   * Don't forget to also call disposeLayer().
    */
-  getLayer(path: string, changeNum: number) {
-    const annotationLayer = new AnnotationLayer(
-      path,
-      changeNum,
-      this.addLayerFunc
-    );
+  createLayer(path: string, changeNum: number) {
+    const callbackFn = this.annotationCallback || (() => {});
+    const annotationLayer = new AnnotationLayer(path, changeNum, callbackFn);
     this.annotationLayers.push(annotationLayer);
     return annotationLayer;
   }
 
+  /**
+   * Called by Gerrit for each diff renderer that had called createLayer().
+   */
   disposeLayer(path: string) {
     this.annotationLayers = this.annotationLayers.filter(
       annotationLayer => annotationLayer.path !== path
@@ -194,6 +136,10 @@
   }
 }
 
+/**
+ * An AnnotationLayer exists for each file that is being rendered. This class is
+ * not exposed to plugins, but being used by Gerrit's diff rendering.
+ */
 export class AnnotationLayer implements DiffLayer {
   private listeners: DiffLayerListener[] = [];
 
@@ -202,13 +148,13 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param addLayerFunc The function
+   * @param annotationCallback The function
    * that will be called when the AnnotationLayer is ready to annotate.
    */
   constructor(
     readonly path: string,
     private readonly changeNum: number,
-    private readonly addLayerFunc: AddLayerFunc
+    private readonly annotationCallback: AnnotationCallback
   ) {
     this.listeners = [];
   }
@@ -230,7 +176,8 @@
   }
 
   /**
-   * Layer method to add annotations to a line.
+   * Called by Gerrit during diff rendering for each line. Delegates to the
+   * plugin provided callback for potentially annotating this line.
    *
    * @param contentEl The DIV.contentText element of the line
    * content to apply the annotation to using annotateRange.
@@ -243,18 +190,20 @@
     lineNumberEl: HTMLElement,
     line: GrDiffLine
   ) {
-    const annotationActionsContext = new GrAnnotationActionsContext(
+    const context = new GrAnnotationActionsContext(
       contentEl,
       lineNumberEl,
       line,
       this.path,
       this.changeNum
     );
-    this.addLayerFunc(annotationActionsContext);
+    this.annotationCallback(context);
   }
 
   /**
-   * Notify Layer listeners of changes to annotations.
+   * Notify layer listeners (which typically is just Gerrit's diff renderer) of
+   * changes to annotations after the diff rendering had already completed. This
+   * is indirectly called by plugins using the AnnotationPluginApi.notify().
    *
    * @param start The line where the update starts.
    * @param end The line where the update ends.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 7ae34cf..9811f99 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -59,9 +59,9 @@
       assert.equal(context.line, line);
       assert.equal(context.changeNum, changeNum);
     };
-    annotationActions.addLayer(testLayerFunc);
+    annotationActions.setLayer(testLayerFunc);
 
-    const annotationLayer = annotationActions.getLayer(
+    const annotationLayer = annotationActions.createLayer(
         '/dummy/path', changeNum);
 
     const lineNumberEl = document.createElement('td');
@@ -72,27 +72,19 @@
   test('add notifier', () => {
     const path1 = '/dummy/path1';
     const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.getLayer(path1, 1);
-    const annotationLayer2 = annotationActions.getLayer(path2, 1);
+    annotationActions.setLayer(context => {});
+    const annotationLayer1 = annotationActions.createLayer(path1, 1);
+    const annotationLayer2 = annotationActions.createLayer(path2, 1);
     const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
     const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
 
-    let notify;
-    let notifyFuncCalled;
-    const notifyFunc = n => {
-      notifyFuncCalled = true;
-      notify = n;
-    };
-    annotationActions.addNotifier(notifyFunc);
-    assert.isTrue(notifyFuncCalled);
-
     // Assert that no layers are invoked with a different path.
-    notify('/dummy/path3', 0, 10, 'right');
+    annotationActions.notify('/dummy/path3', 0, 10, 'right');
     assert.isFalse(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
     // Assert that only the 1st layer is invoked with path1.
-    notify(path1, 0, 10, 'right');
+    annotationActions.notify(path1, 0, 10, 'right');
     assert.isTrue(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
@@ -101,7 +93,7 @@
     layer2Spy.resetHistory();
 
     // Assert that only the 2nd layer is invoked with path2.
-    notify(path2, 0, 20, 'left');
+    annotationActions.notify(path2, 0, 20, 'left');
     assert.isFalse(layer1Spy.called);
     assert.isTrue(layer2Spy.called);
   });
@@ -143,7 +135,8 @@
   });
 
   test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
+    annotationActions.setLayer(context => {});
+    const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
     let listenerCalledTimes = 0;
     const startRange = 10;
     const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 2f2b5ce..a4c6974 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -60,7 +60,7 @@
 }
 
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
-  private _el?: GrChangeActionsElement;
+  private el?: GrChangeActionsElement;
 
   RevisionActions = RevisionActions;
 
@@ -80,7 +80,7 @@
       console.warn('changeActions() is not ready');
       return;
     }
-    this._el = el;
+    this.el = el;
   }
 
   /**
@@ -88,7 +88,7 @@
    * element and retrieve if the interface was created before element.
    */
   private ensureEl(): GrChangeActionsElement {
-    if (!this._el) {
+    if (!this.el) {
       const sharedApiElement = appContext.jsApiService;
       this.setEl(
         (sharedApiElement.getElement(
@@ -96,7 +96,7 @@
         ) as unknown) as GrChangeActionsElement
       );
     }
-    return this._el!;
+    return this.el!;
   }
 
   addPrimaryActionKey(key: PrimaryActionKey) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 27bc591..7f9218a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -250,7 +250,7 @@
    *   });
    * });
    *
-   * // Listen on your-special-event from pluignB
+   * // Listen on your-special-event from pluginB
    * Gerrit.install(pluginB => {
    *   Gerrit.on("your-special-event", ({plugin}) => {
    *     // do something, plugin is pluginA
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 830fb92..8689ad2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -252,8 +252,8 @@
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
       try {
-        const layer = annotationApi.getLayer(path, changeNum);
-        layers.push(layer);
+        const layer = annotationApi.createLayer(path, changeNum);
+        if (layer) layers.push(layer);
       } catch (err) {
         this.reporting.error(err);
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 6a8a0dd..12a4056 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -426,7 +426,7 @@
             // eslint-disable-next-line no-invalid-this
             const grPopupInterface = this;
             assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface._moduleName, 'some-name');
+            assert.equal(grPopupInterface.moduleName, 'some-name');
           });
       plugin.popup('some-name');
       assert.isTrue(openStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 21e4876..2135c30 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -28,7 +28,7 @@
 }
 
 export class GrPluginActionContext {
-  private _popups: PopupPluginApi[] = [];
+  private popups: PopupPluginApi[] = [];
 
   constructor(
     public readonly plugin: PluginApi,
@@ -44,15 +44,15 @@
         throw new Error('Popup element not found');
       }
       popupEl.appendChild(element);
-      this._popups.push(popApi);
+      this.popups.push(popApi);
     });
   }
 
   hide() {
-    for (const popupApi of this._popups) {
+    for (const popupApi of this.popups) {
       popupApi.close();
     }
-    this._popups.splice(0);
+    this.popups.splice(0);
   }
 
   refresh() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 2752c74..82df2fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -51,10 +51,10 @@
 
   private readonly _importedUrls = new Set<string>();
 
-  private _pluginLoaded = false;
+  private pluginLoaded = false;
 
   setPluginsReady() {
-    this._pluginLoaded = true;
+    this.pluginLoaded = true;
   }
 
   onNewEndpoint(endpoint: string, callback: Callback) {
@@ -125,7 +125,7 @@
     // one register before plugins ready
     // the other done after, then only the later one will have the callbacks
     // invoked.
-    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+    if (this.pluginLoaded && this._callbacks.has(endpoint)) {
       this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 8c0fce26..db34e5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -74,7 +74,7 @@
 const UNKNOWN_PLUGIN_PREFIX = '__$$__';
 
 // Current API version for Plugin,
-// plugins with incompatible version will not be laoded.
+// plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
 /**
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 6b62291..f5b1fca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -225,7 +225,7 @@
     assert.isTrue(alertStub.calledTwice);
   });
 
-  test('plugins installed failed becasue of wrong version', async () => {
+  test('plugins installed failed because of wrong version', async () => {
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
       'http://test.com/plugins/bar/static/test.js',
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 45ffdcd9..e7843af 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -78,14 +78,14 @@
 export class Plugin implements PluginApi {
   readonly _url?: URL;
 
-  private _domHooks: GrDomHooksManager;
+  private domHooks: GrDomHooksManager;
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
   private readonly jsApi = appContext.jsApiService;
 
   constructor(url?: string) {
-    this._domHooks = new GrDomHooksManager(this);
+    this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
       console.warn(
@@ -151,7 +151,7 @@
     const type =
       options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
     const slot = (options && options.slot) || '';
-    const domHook = this._domHooks.getDomHook(endpoint, moduleName);
+    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
index a335db7..8581a0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -46,7 +46,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index d29cba7..20e5296 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -56,7 +56,7 @@
    * @event fullscreen-overlay-opened
    */
 
-  private _fullScreenOpen = false;
+  private fullScreenOpen = false;
 
   private _boundHandleClose: () => void = () => super.close();
 
@@ -99,7 +99,7 @@
       super.open.apply(this);
       if (this._isMobile()) {
         fireEvent(this, 'fullscreen-overlay-opened');
-        this._fullScreenOpen = true;
+        this.fullScreenOpen = true;
       }
       this._awaitOpen(resolve, reject);
     });
@@ -112,9 +112,9 @@
   // called after iron-overlay is closed. Does not actually close the overlay
   _overlayClosed() {
     window.removeEventListener('popstate', this._boundHandleClose);
-    if (this._fullScreenOpen) {
+    if (this.fullScreenOpen) {
       fireEvent(this, 'fullscreen-overlay-closed');
-      this._fullScreenOpen = false;
+      this.fullScreenOpen = false;
     }
     if (this.returnFocusTo) {
       this.returnFocusTo.focus();
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
index 4b6ae34..72c3399 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -57,11 +57,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isTrue(element._fullScreenOpen);
+    assert.isTrue(element.fullScreenOpen);
     assert.isTrue(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isTrue(closeHandler.called);
   });
 
@@ -75,11 +75,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(closeHandler.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index f3be790..ec59ddc 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -2234,13 +2234,13 @@
     if (!basePatchNum && !patchNum && !path) {
       return this._getDiffComments(changeNum, '/comments', {
         'enable-context': true,
-        'context-padding': 5,
+        'context-padding': 3,
       });
     }
     return this._getDiffComments(
       changeNum,
       '/comments',
-      {'enable-context': true, 'context-padding': 5},
+      {'enable-context': true, 'context-padding': 3},
       basePatchNum,
       patchNum,
       path
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index fa2a28e..89abd57 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -75,7 +75,7 @@
 export class SiteBasedCache {
   // TODO(TS): Type looks unusual. Fix it.
   // Container of per-canonical-path caches.
-  private readonly _data = new Map<
+  private readonly data = new Map<
     string | undefined,
     unknown | Map<string, ParsedJSON | null>
   >();
@@ -93,13 +93,13 @@
 
   // Returns the cache for the current canonical path.
   _cache(): Map<string, unknown> {
-    if (!this._data.has(window.CANONICAL_PATH)) {
-      this._data.set(
+    if (!this.data.has(window.CANONICAL_PATH)) {
+      this.data.set(
         window.CANONICAL_PATH,
         new Map<string, ParsedJSON | null>()
       );
     }
-    return this._data.get(window.CANONICAL_PATH) as Map<
+    return this.data.get(window.CANONICAL_PATH) as Map<
       string,
       ParsedJSON | null
     >;
@@ -140,7 +140,7 @@
         newMap.set(key, value);
       }
     }
-    this._data.set(window.CANONICAL_PATH, newMap);
+    this.data.set(window.CANONICAL_PATH, newMap);
   }
 }
 
@@ -149,25 +149,25 @@
 };
 
 export class FetchPromisesCache {
-  private _data: FetchPromisesCacheData;
+  private data: FetchPromisesCacheData;
 
   constructor() {
-    this._data = {};
+    this.data = {};
   }
 
   public testOnlyGetData() {
-    return this._data;
+    return this.data;
   }
 
   /**
    * @return true only if a value for a key sets and it is not undefined
    */
   has(key: string): boolean {
-    return !!this._data[key];
+    return !!this.data[key];
   }
 
   get(key: string) {
-    return this._data[key];
+    return this.data[key];
   }
 
   /**
@@ -175,17 +175,17 @@
    *     mark key as deleted.
    */
   set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
-    this._data[key] = value;
+    this.data[key] = value;
   }
 
   invalidatePrefix(prefix: string) {
     const newData: FetchPromisesCacheData = {};
-    Object.entries(this._data).forEach(([key, value]) => {
+    Object.entries(this.data).forEach(([key, value]) => {
       if (!key.startsWith(prefix)) {
         newData[key] = value;
       }
     });
-    this._data = newData;
+    this.data = newData;
   }
 }
 export type FetchParams = {
@@ -457,7 +457,7 @@
    * Send an XHR.
    *
    * @return Promise resolves to Response/ParsedJSON only if the request is successful
-   *     (i.e. no exception and response.ok is trsue). If response fails then
+   *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
   send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 1a1062c..95e06c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -81,9 +81,9 @@
   // type. This class should be refactored to avoid reassignment.
   private readonly result: ChangeInfoParserInput;
 
-  private _batch: ParserBatch | null = null;
+  private batch: ParserBatch | null = null;
 
-  private _updateItems: {[accountId: string]: UpdateItem} | null = null;
+  private updateItems: {[accountId: string]: UpdateItem} | null = null;
 
   private readonly _lastState: {[accountId: string]: ReviewerState} = {};
 
@@ -105,7 +105,7 @@
    * Is a part of _groupUpdates(). Creates a new batch of updates.
    */
   private _startBatch(update: ReviewerUpdateInfo): ParserBatch {
-    this._updateItems = {};
+    this.updateItems = {};
     return {
       author: update.updated_by,
       date: update.updated,
@@ -121,7 +121,7 @@
    */
   private _completeBatch(batch: ParserBatch) {
     const items = [];
-    for (const [accountId, item] of Object.entries(this._updateItems ?? {})) {
+    for (const [accountId, item] of Object.entries(this.updateItems ?? {})) {
       if (this._lastState[accountId] !== item.state) {
         this._lastState[accountId] = item.state;
         items.push(item);
@@ -142,27 +142,27 @@
   _groupUpdates(): ParserBatchWithNonEmptyUpdates[] {
     const updates = this.result.reviewer_updates;
     const newUpdates = updates.reduce((newUpdates, update) => {
-      if (!this._batch) {
-        this._batch = this._startBatch(update);
+      if (!this.batch) {
+        this.batch = this._startBatch(update);
       }
       const updateDate = parseDate(update.updated).getTime();
-      const batchUpdateDate = parseDate(this._batch.date).getTime();
+      const batchUpdateDate = parseDate(this.batch.date).getTime();
       const reviewerId = accountKey(update.reviewer);
       if (
         updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS ||
-        update.updated_by._account_id !== this._batch.author._account_id
+        update.updated_by._account_id !== this.batch.author._account_id
       ) {
         // Next sequential update should form new group.
-        this._completeBatch(this._batch);
-        if (isParserBatchWithNonEmptyUpdates(this._batch)) {
-          newUpdates.push(this._batch);
+        this._completeBatch(this.batch);
+        if (isParserBatchWithNonEmptyUpdates(this.batch)) {
+          newUpdates.push(this.batch);
         }
-        this._batch = this._startBatch(update);
+        this.batch = this._startBatch(update);
       }
-      // _startBatch assigns _updateItems. When _groupUpdates is calling,
-      // _batch and _updateItems are not set => _startBatch is called. The
-      // _startBatch method assigns _updateItems
-      const updateItems = this._updateItems!;
+      // _startBatch assigns updateItems. When _groupUpdates is calling,
+      // batch and updateItems are not set => _startBatch is called. The
+      // _startBatch method assigns updateItems
+      const updateItems = this.updateItems!;
       updateItems[reviewerId] = {
         reviewer: update.reviewer,
         state: update.state,
@@ -174,8 +174,8 @@
     }, [] as ParserBatchWithNonEmptyUpdates[]);
     // reviewer_updates always has at least 1 item
     // (otherwise parse is not created) => updates.reduce calls callback
-    // at least once and callback assigns this._batch
-    const batch = this._batch!;
+    // at least once and callback assigns this.batch
+    const batch = this.batch!;
     this._completeBatch(batch);
     if (isParserBatchWithNonEmptyUpdates(batch)) {
       newUpdates.push(batch);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b0b40dd..885db2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -112,7 +112,7 @@
   @property({type: Boolean})
   hideBorder = false;
 
-  /** Text input should be rendered in monspace font.  */
+  /** Text input should be rendered in monospace font.  */
   @property({type: Boolean})
   monospace = false;
 
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 77d2d00..75ad608 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -69,13 +69,13 @@
       // Handler for mouseenter event
       private mouseenterHandler?: (e: MouseEvent) => void;
 
-      // Hanlder for scrolling on window
+      // Handler for scrolling on window
       private readonly windowScrollHandler: () => void;
 
-      // Hanlder for showing the tooltip, will be attached to certain events
+      // Handler for showing the tooltip, will be attached to certain events
       private readonly showHandler: () => void;
 
-      // Hanlder for hiding the tooltip, will be attached to certain events
+      // Handler for hiding the tooltip, will be attached to certain events
       private readonly hideHandler: () => void;
 
       // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index 662d6bf..57e034f 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -29,7 +29,7 @@
 // is used. To ensure that this import can't be avoided, the second parameter
 // is added. Usage example:
 // class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
 // defined as an object, not as IronFitBehavior instance.
 
 export const IronFitMixin = <T extends Constructor<PolymerElement>>(
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 9bd2f5a..ab85b87 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -107,7 +107,6 @@
   CustomKeyboardEvent,
   ShortcutTriggeredEventDetail,
 } from '../../types/events';
-import {appContext} from '../../services/app-context';
 
 /** Enum for all special shortcuts */
 export enum SPECIAL_SHORTCUT {
@@ -176,7 +175,7 @@
   VISIBLE_LINE = 'VISIBLE_LINE',
   NEXT_CHUNK = 'NEXT_CHUNK',
   PREV_CHUNK = 'PREV_CHUNK',
-  EXPAND_ALL_DIFF_CONTEXT = 'EXPAND_ALL_DIFF_CONTEXT',
+  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
   NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
   PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
   EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
@@ -404,9 +403,9 @@
   'Go to previous diff chunk'
 );
 _describe(
-  Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
   ShortcutSection.DIFFS,
-  'Expand all diff context'
+  'Toggle all diff context'
 );
 _describe(
   Shortcut.NEXT_COMMENT_THREAD,
@@ -545,7 +544,7 @@
 }
 
 /**
- * Shortcut manager, holds all hosts, bindings and listners.
+ * Shortcut manager, holds all hosts, bindings and listeners.
  */
 export class ShortcutManager {
   private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
@@ -806,10 +805,6 @@
 
       ShortcutSection = ShortcutSection;
 
-      private _disableKeyboardShortcuts = false;
-
-      private restApiService = appContext.restApiService;
-
       modifierPressed(event: CustomKeyboardEvent) {
         /* We are checking for g/v as modifiers pressed. There are cases such as
          * pressing v and then /, where we want the handler for / to be triggered.
@@ -831,7 +826,6 @@
       }
 
       shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
-        if (this._disableKeyboardShortcuts) return true;
         const e = getKeyboardEvent(event);
         // TODO(TS): maybe override the EventApi, narrow it down to Element always
         const target = (dom(e) as EventApi).rootTarget as Element;
@@ -932,13 +926,6 @@
       /** @override */
       connectedCallback() {
         super.connectedCallback();
-
-        this.restApiService.getPreferences().then(prefs => {
-          if (prefs?.disable_keyboard_shortcuts) {
-            this._disableKeyboardShortcuts = true;
-          }
-        });
-
         const shortcuts = shortcutManager.attachHost(this);
         if (!shortcuts) {
           return;
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 7a6253b..f03c7e6 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -254,6 +254,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resize-observer-browser",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 3351386..5e15990 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,6 +25,7 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
diff --git a/polygerrit-ui/app/samples/coverage-plugin.js b/polygerrit-ui/app/samples/coverage-plugin.js
index 9b2b687..8d321c7 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.js
+++ b/polygerrit-ui/app/samples/coverage-plugin.js
@@ -41,7 +41,7 @@
   const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
   const emptyStyle = styleApi.css('');
 
-  annotationApi.addLayer(context => {
+  annotationApi.setLayer(context => {
     if (Object.keys(coverageData).length === 0) {
       // Coverage data is not ready yet.
       return;
@@ -64,19 +64,13 @@
       }
     }
   }).enableToggleCheckbox('Display Coverage', checkbox => {
-    // Checkbox is attached so now add the notifier that will be controlled
-    // by the checkbox.
-    // Checkbox will only be added to the file diff page, in the top right
-    // section near the "Diff view".
-    annotationApi.addNotifier(notifyFunc => {
-      populateWithDummyData(coverageData);
-      checkbox.disabled = false;
-      checkbox.onclick = e => {
-        displayCoverage = e.target.checked;
-        Object.keys(coverageData).forEach(file => {
-          notifyFunc(file, 0, coverageData[file].totalLines, 'right');
-        });
-      };
-    });
+    populateWithDummyData(coverageData);
+    checkbox.disabled = false;
+    checkbox.onclick = e => {
+      displayCoverage = e.target.checked;
+      Object.keys(coverageData).forEach(file => {
+        annotationApi.notify(file, 0, coverageData[file].totalLines, 'right');
+      });
+    };
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
index 7969b84..5818003 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
@@ -19,10 +19,10 @@
 import {AccountInfo} from '../../types/common';
 
 export class GrEmailSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
+    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
       if (!accounts) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
index 1cf1c39..ff113fb 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
@@ -19,10 +19,10 @@
 import {GroupBaseInfo} from '../../types/common';
 
 export class GrGroupSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedGroups(`${input}`).then(groups => {
+    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
       if (!groups) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 1572ba1..45116aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -73,13 +73,13 @@
     }
   }
 
-  private _initPromise?: Promise<void>;
+  private initPromise?: Promise<void>;
 
-  private _config?: ServerInfo;
+  private config?: ServerInfo;
 
-  private _loggedIn = false;
+  private loggedIn = false;
 
-  private _initialized = false;
+  private initialized = false;
 
   private constructor(
     private readonly _restAPI: RestApiService,
@@ -87,26 +87,25 @@
   ) {}
 
   init() {
-    if (this._initPromise) {
-      return this._initPromise;
+    if (this.initPromise) {
+      return this.initPromise;
     }
     const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this._config = cfg;
+      this.config = cfg;
     });
     const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
-    this._initPromise = Promise.all([
-      getConfigPromise,
-      getLoggedInPromise,
-    ]).then(() => {
-      this._initialized = true;
-    });
-    return this._initPromise;
+    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
+      () => {
+        this.initialized = true;
+      }
+    );
+    return this.initPromise;
   }
 
   getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this._initialized || !this._loggedIn) {
+    if (!this.initialized || !this.loggedIn) {
       return Promise.resolve([]);
     }
 
@@ -117,7 +116,7 @@
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
-        name: getAccountDisplayName(this._config, suggestion.account),
+        name: getAccountDisplayName(this.config, suggestion.account),
         value: suggestion,
       };
     }
@@ -133,7 +132,7 @@
     if (isAccountSuggestions(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
-        name: getAccountDisplayName(this._config, suggestion),
+        name: getAccountDisplayName(this.config, suggestion),
         value: {account: suggestion, count: 1},
       };
     }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
index c7de24a..d3cad45 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
@@ -130,7 +130,7 @@
           value: {account: {}},
         });
 
-        provider._config = {
+        provider.config = {
           user: {
             anonymous_coward_name: 'Anonymous Coward Name',
           },
@@ -179,10 +179,10 @@
       });
 
       test('getSuggestions short circuits when logged out', () => {
-        provider._loggedIn = false;
+        provider.loggedIn = false;
         return provider.getSuggestions('').then(() => {
           assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider._loggedIn = true;
+          provider.loggedIn = true;
           return provider.getSuggestions('').then(() => {
             assert.isTrue(getChangeSuggestedReviewersStub.called);
           });
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 09e0724..0369ccf 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -70,7 +70,7 @@
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
-    changeService: () => new ChangeService(appContext.restApiService),
+    changeService: () => new ChangeService(),
     checksService: () => new ChecksService(),
     jsApiService: () => new GrJsApiInterface(),
   });
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index b2bdcfe..e7472de 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -46,10 +46,18 @@
 // Must only be used by the change service or whatever is in control of this
 // model.
 export function updateState(change?: ParsedChangeInfo) {
-  privateState$.next({
-    ...privateState$.getValue(),
-    change,
-  });
+  const current = privateState$.getValue();
+  // We want to make it easy for subscribers to react to change changes, so we
+  // are explicitly emitting and additional `undefined` when the change number
+  // changes. So if you are subscribed to the latestPatchsetNumber for example,
+  // then you can rely on emissions even if the old and the new change have the
+  // same latestPatchsetNumber.
+  if (change !== undefined && current.change !== undefined) {
+    if (change._number !== current.change._number) {
+      privateState$.next({...current, change: undefined});
+    }
+  }
+  privateState$.next({...current, change});
 }
 
 /**
@@ -91,9 +99,6 @@
  *
  * Note that this selector can emit a patchNum without the change being
  * available!
- *
- * TODO: It would be good to assert/enforce somehow that currentPatchNum$ cannot
- * emit 'PARENT'.
  */
 export const currentPatchNum$: Observable<
   PatchSetNum | undefined
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 6a9a5e9..c292fb5 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -14,28 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {routerChangeNum$} from '../router/router-model';
 import {updateState} from './change-model';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {switchMap, tap} from 'rxjs/operators';
-import {of, from} from 'rxjs';
+import {ParsedChangeInfo} from '../../types/types';
 
 export class ChangeService {
-  private routerChangeNumEffect = routerChangeNum$.pipe(
-    switchMap(changeNum => {
-      if (!changeNum) return of(undefined);
-      return from(this.restApiService.getChangeDetail(changeNum));
-    }),
-    tap(change => {
-      updateState(change ?? undefined);
-    })
-  );
-
-  constructor(private readonly restApiService: RestApiService) {
-    this.routerChangeNumEffect.subscribe();
+  constructor() {
+    // TODO: In the future we will want to make restApiService.getChangeDetail()
+    // calls from a switchMap() here. For now just make sure to invalidate the
+    // change when no changeNum is set.
+    routerChangeNum$.subscribe(changeNum => {
+      if (!changeNum) updateState(undefined);
+    });
   }
 
-  // TODO: Remove.
-  dontDoAnything() {}
+  /**
+   * This is a temporary indirection between change-view, which currently
+   * manages what the current change is, and the change-model, which will
+   * become the source of truth in the future. We will extract a substantial
+   * amount of code from change-view and move it into this change-service. This
+   * will take some time ...
+   */
+  updateChange(change: ParsedChangeInfo) {
+    updateState(change);
+  }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 9546d0b..1c5b862 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -17,6 +17,7 @@
 
 import {BehaviorSubject, Observable} from 'rxjs';
 import {
+  Action,
   Category,
   CheckResult,
   CheckRun,
@@ -25,6 +26,7 @@
   RunStatus,
 } from '../../api/checks';
 import {distinctUntilChanged, map} from 'rxjs/operators';
+import {PatchSetNumber} from '../../types/common';
 
 // This is a convenience type for working with results, because when working
 // with a bunch of results you will typically also want to know about the run
@@ -33,27 +35,63 @@
 
 interface ChecksProviderState {
   pluginName: string;
+  loading: boolean;
   config?: ChecksApiConfig;
   runs: CheckRun[];
+  actions: Action[];
 }
 
 interface ChecksState {
-  [name: string]: ChecksProviderState;
+  patchsetNumber?: PatchSetNumber;
+  providerNameToState: {
+    [name: string]: ChecksProviderState;
+  };
 }
 
-const initialState: ChecksState = {};
+const initialState: ChecksState = {
+  providerNameToState: {},
+};
 
 const privateState$ = new BehaviorSubject(initialState);
 
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
-export const aPluginHasRegistered = checksState$.pipe(
+export const checksPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumber),
+  distinctUntilChanged()
+);
+
+export const checksProviderState$ = checksState$.pipe(
+  map(state => state.providerNameToState),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksProviderState$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
-export const allRuns$ = checksState$.pipe(
+export const someProvidersAreLoading$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state).some(providerState => providerState.loading);
+  }),
+  distinctUntilChanged()
+);
+
+export const allActions$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    );
+  })
+);
+
+export const allRuns$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -65,7 +103,19 @@
   })
 );
 
-export const allResults$ = checksState$.pipe(
+export const checkToPluginMap$ = checksProviderState$.pipe(
+  map(state => {
+    const map = new Map<string, string>();
+    for (const [pluginName, providerState] of Object.entries(state)) {
+      for (const run of providerState.runs) {
+        map.set(run.checkName, pluginName);
+      }
+    }
+    return map;
+  })
+);
+
+export const allResults$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state)
       .reduce(
@@ -90,10 +140,13 @@
   config?: ChecksApiConfig
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState[pluginName] = {
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
     pluginName,
+    loading: false,
     config,
     runs: [],
+    actions: [],
   };
   privateState$.next(nextState);
 }
@@ -162,11 +215,34 @@
   status: RunStatus.COMPLETED,
 };
 
-export function updateStateSetResults(pluginName: string, runs: CheckRun[]) {
+export function updateStateSetLoading(pluginName: string) {
   const nextState = {...privateState$.getValue()};
-  nextState[pluginName] = {
-    ...nextState[pluginName],
-    runs: [...runs],
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: true,
   };
   privateState$.next(nextState);
 }
+
+export function updateStateSetResults(
+  pluginName: string,
+  runs: CheckRun[],
+  actions: Action[] = []
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: false,
+    runs: [...runs],
+    actions: [...actions],
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+  const nextState = {...privateState$.getValue()};
+  nextState.patchsetNumber = patchsetNumber;
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index a8dd8b8..3ce714a 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -18,18 +18,25 @@
 import {
   switchMap,
   takeWhile,
-  tap,
   throttleTime,
   withLatestFrom,
 } from 'rxjs/operators';
 import {
+  ChangeData,
   ChecksApiConfig,
   ChecksProvider,
   FetchResponse,
   ResponseCode,
 } from '../../api/checks';
-import {change$, currentPatchNum$} from '../change/change-model';
-import {updateStateSetProvider, updateStateSetResults} from './checks-model';
+import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
+import {
+  updateStateSetLoading,
+  checkToPluginMap$,
+  updateStateSetProvider,
+  updateStateSetResults,
+  checksPatchsetNumber$,
+  updateStateSetPatchset,
+} from './checks-model';
 import {
   BehaviorSubject,
   combineLatest,
@@ -38,13 +45,27 @@
   of,
   Subject,
 } from 'rxjs';
+import {PatchSetNumber} from '../../types/common';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
 
   private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
 
-  private changeAndPatchNum$ = change$.pipe(withLatestFrom(currentPatchNum$));
+  private checkToPluginMap = new Map<string, string>();
+
+  constructor() {
+    checkToPluginMap$.subscribe(map => {
+      this.checkToPluginMap = map;
+    });
+    latestPatchNum$.subscribe(num => {
+      updateStateSetPatchset(num);
+    });
+  }
+
+  setPatchset(num: PatchSetNumber) {
+    updateStateSetPatchset(num);
+  }
 
   reload(pluginName: string) {
     this.reloadSubjects[pluginName].next();
@@ -54,6 +75,12 @@
     Object.keys(this.providers).forEach(key => this.reload(key));
   }
 
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
   register(
     pluginName: string,
     provider: ChecksProvider,
@@ -64,29 +91,43 @@
     updateStateSetProvider(pluginName, config);
     // Both, changed numbers and and announceUpdate request should trigger.
     combineLatest([
-      this.changeAndPatchNum$,
+      changeNum$,
+      checksPatchsetNumber$,
       this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
     ])
       .pipe(
         takeWhile(_ => !!this.providers[pluginName]),
+        withLatestFrom(change$),
         switchMap(
-          ([[change, patchNum], _]): Observable<FetchResponse> => {
-            if (!change || !patchNum || typeof patchNum !== 'number') {
+          ([[changeNum, patchNum, _], change]): Observable<FetchResponse> => {
+            if (
+              !change ||
+              !changeNum ||
+              !patchNum ||
+              typeof patchNum !== 'number'
+            ) {
               return of({
                 responseCode: ResponseCode.OK,
                 runs: [],
               });
             }
-            return from(
-              this.providers[pluginName].fetch(change._number, patchNum)
-            );
+            const data: ChangeData = {
+              changeNumber: changeNum,
+              patchsetNumber: patchNum,
+              repo: change.project,
+            };
+            updateStateSetLoading(pluginName);
+            return from(this.providers[pluginName].fetch(data));
           }
-        ),
-        tap(response => {
-          updateStateSetResults(pluginName, response.runs ?? []);
-        })
+        )
       )
-      .subscribe(() => {});
+      .subscribe(response => {
+        updateStateSetResults(
+          pluginName,
+          response.runs ?? [],
+          response.actions
+        );
+      });
     this.reload(pluginName);
   }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 30b82b6..ea532ea 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -24,7 +24,7 @@
   return undefined;
 }
 
-export function iconForCategory(category: Category) {
+export function iconForCategory(category: Category | 'SUCCESS') {
   switch (category) {
     case Category.ERROR:
       return 'error';
@@ -32,6 +32,8 @@
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
+    case 'SUCCESS':
+      return 'check-circle-outline';
     default:
       assertNever(category, `Unsupported category: ${category}`);
   }
@@ -74,8 +76,12 @@
 }
 
 export function iconForRun(run: CheckRun) {
-  const category = worstCategory(run);
-  return category ? iconForCategory(category) : iconForStatus(run.status);
+  if (run.status !== RunStatus.COMPLETED) {
+    return iconForStatus(run.status);
+  } else {
+    const category = worstCategory(run);
+    return category ? iconForCategory(category) : iconForStatus(run.status);
+  }
 }
 
 export function iconForStatus(status: RunStatus) {
@@ -135,3 +141,30 @@
       return 2;
   }
 }
+
+export interface ActionTriggeredEventDetail {
+  action: Action;
+  run?: CheckRun;
+}
+
+export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'action-triggered': ActionTriggeredEvent;
+  }
+}
+
+export function fireActionTriggered(
+  target: EventTarget,
+  action: Action,
+  run?: CheckRun
+) {
+  target.dispatchEvent(
+    new CustomEvent('action-triggered', {
+      detail: {action, run},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 8fe7c35..6fadfde 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -61,26 +61,26 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private _authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<Response>;
 
   private _last_auth_check_time: number = Date.now();
 
   private _status = AuthStatus.UNDETERMINED;
 
-  private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+  private retriesLeft = MAX_GET_TOKEN_RETRIES;
 
-  private _cachedTokenPromise: Promise<Token | null> | null = null;
+  private cachedTokenPromise: Promise<Token | null> | null = null;
 
-  private _type?: AuthType;
+  private type?: AuthType;
 
-  private _defaultOptions: AuthRequestInit = {};
+  private defaultOptions: AuthRequestInit = {};
 
-  private _getToken: GetTokenCallback;
+  private getToken: GetTokenCallback;
 
   public eventEmitter: EventEmitterService;
 
   constructor(eventEmitter: EventEmitterService) {
-    this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+    this.getToken = () => Promise.resolve(this.cachedTokenPromise);
     this.eventEmitter = eventEmitter;
   }
 
@@ -93,15 +93,15 @@
    */
   authCheck(): Promise<boolean> {
     if (
-      !this._authCheckPromise ||
+      !this.authCheckPromise ||
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
       this._last_auth_check_time = Date.now();
     }
 
-    return this._authCheckPromise
+    return this.authCheckPromise
       .then(res => {
         // auth-check will return 204 if authed
         // treat the rest as unauthed
@@ -115,14 +115,14 @@
       })
       .catch(() => {
         this._setStatus(AuthStatus.ERROR);
-        // Reset _authCheckPromise to avoid caching the failed promise
-        this._authCheckPromise = undefined;
+        // Reset authCheckPromise to avoid caching the failed promise
+        this.authCheckPromise = undefined;
         return false;
       });
   }
 
   clearCache() {
-    this._authCheckPromise = undefined;
+    this.authCheckPromise = undefined;
   }
 
   private _setStatus(status: AuthStatus) {
@@ -149,15 +149,15 @@
    * Enable cross-domain authentication using OAuth access token.
    */
   setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
-    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    this.retriesLeft = MAX_GET_TOKEN_RETRIES;
     if (getToken) {
-      this._type = AuthType.ACCESS_TOKEN;
-      this._cachedTokenPromise = null;
-      this._getToken = getToken;
+      this.type = AuthType.ACCESS_TOKEN;
+      this.cachedTokenPromise = null;
+      this.getToken = getToken;
     }
-    this._defaultOptions = {};
+    this.defaultOptions = {};
     if (defaultOptions) {
-      this._defaultOptions.credentials = defaultOptions.credentials;
+      this.defaultOptions.credentials = defaultOptions.credentials;
     }
   }
 
@@ -167,10 +167,10 @@
   fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
     const options: AuthRequestInitWithHeaders = {
       headers: new Headers(),
-      ...this._defaultOptions,
+      ...this.defaultOptions,
       ...opt_options,
     };
-    if (this._type === AuthType.ACCESS_TOKEN) {
+    if (this.type === AuthType.ACCESS_TOKEN) {
       return this._getAccessToken().then(accessToken =>
         this._fetchWithAccessToken(url, options, accessToken)
       );
@@ -224,17 +224,17 @@
   }
 
   private _getAccessToken(): Promise<string | null> {
-    if (!this._cachedTokenPromise) {
-      this._cachedTokenPromise = this._getToken();
+    if (!this.cachedTokenPromise) {
+      this.cachedTokenPromise = this.getToken();
     }
-    return this._cachedTokenPromise.then(token => {
+    return this.cachedTokenPromise.then(token => {
       if (this._isTokenValid(token)) {
-        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+        this.retriesLeft = MAX_GET_TOKEN_RETRIES;
         return token.access_token;
       }
-      if (this._retriesLeft > 0) {
-        this._retriesLeft--;
-        this._cachedTokenPromise = null;
+      if (this.retriesLeft > 0) {
+        this.retriesLeft--;
+        this.cachedTokenPromise = null;
         return this._getAccessToken();
       }
       // Fall back to anonymous access.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 4ca983a..4196513 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -61,7 +61,7 @@
   timeEnd(name: string, eventDetails?: EventDetails): void;
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -74,7 +74,7 @@
     denominator: number
   ): void;
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -99,7 +99,7 @@
   reportExecution(id: string, details: EventDetails): void;
   reportInteraction(eventName: string, details?: EventDetails): void;
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index f80cb75..631a4e0 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -292,17 +292,17 @@
 
   private readonly _baselines = STARTUP_TIMERS;
 
-  private _reportRepoName: string | undefined;
+  private reportRepoName: string | undefined;
 
-  private _reportChangeId: NumericChangeId | undefined;
+  private reportChangeId: NumericChangeId | undefined;
 
-  private _timers: {timeBetweenDraftActions: Timer | null} = {
+  private timers: {timeBetweenDraftActions: Timer | null} = {
     timeBetweenDraftActions: null,
   };
 
-  private _pending: PendingReportInfo[] = [];
+  private pending: PendingReportInfo[] = [];
 
-  private _slowRpcList: SlowRpcCall[] = [];
+  private slowRpcList: SlowRpcCall[] = [];
 
   /**
    * Keeps track of which ids were already reported to have been executed.
@@ -321,7 +321,7 @@
   }
 
   private get slowRpcSnapshot() {
-    return (this._slowRpcList || []).slice();
+    return (this.slowRpcList || []).slice();
   }
 
   private _arePluginsLoaded() {
@@ -366,16 +366,16 @@
     }
 
     // We report events immediately when metrics plugin is loaded
-    if (this._isMetricsPluginLoaded() && !this._pending.length) {
+    if (this._isMetricsPluginLoaded() && !this.pending.length) {
       this._reportEvent(eventInfo, noLog);
     } else {
       // We cache until metrics plugin is loaded
-      this._pending.push([eventInfo, noLog]);
+      this.pending.push([eventInfo, noLog]);
       if (this._isMetricsPluginLoaded()) {
-        this._pending.forEach(([eventInfo, opt_noLog]) => {
+        this.pending.forEach(([eventInfo, opt_noLog]) => {
           this._reportEvent(eventInfo, opt_noLog);
         });
-        this._pending = [];
+        this.pending = [];
       }
     }
   }
@@ -417,11 +417,11 @@
       eventInfo.eventDetails = JSON.stringify(eventDetails);
     }
 
-    if (this._reportRepoName) {
-      eventInfo.repoName = this._reportRepoName;
+    if (this.reportRepoName) {
+      eventInfo.repoName = this.reportRepoName;
     }
-    if (this._reportChangeId) {
-      eventInfo.changeId = `${this._reportChangeId}`;
+    if (this.reportChangeId) {
+      eventInfo.changeId = `${this.reportChangeId}`;
     }
 
     const isInBackgroundTab = document.visibilityState === 'hidden';
@@ -501,10 +501,10 @@
     this.time(TIMER.DIFF_VIEW_DISPLAYED);
     this.time(TIMER.DIFF_VIEW_LOAD_FULL);
     this.time(TIMER.FILE_LIST_DISPLAYED);
-    this._reportRepoName = undefined;
-    this._reportChangeId = undefined;
+    this.reportRepoName = undefined;
+    this.reportChangeId = undefined;
     // reset slow rpc list since here start page loads which report these rpcs
-    this._slowRpcList = [];
+    this.slowRpcList = [];
     this.hiddenDurationTimer.reset();
   }
 
@@ -651,7 +651,7 @@
     if (baseTime !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
-      // Microsft Edge does not handle the 2nd param correctly
+      // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
@@ -659,7 +659,7 @@
 
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -703,7 +703,7 @@
   }
 
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object to for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -765,7 +765,7 @@
       true
     );
     if (elapsed >= SLOW_RPC_THRESHOLD) {
-      this._slowRpcList.push({anonymizedUrl, elapsed});
+      this.slowRpcList.push({anonymizedUrl, elapsed});
     }
   }
 
@@ -805,17 +805,17 @@
   }
 
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction() {
     // If there is no timer defined, then this is the first interaction.
     // Set up the timer so that it's ready to record the intervening time when
     // called again.
-    const timer = this._timers.timeBetweenDraftActions;
+    const timer = this.timers.timeBetweenDraftActions;
     if (!timer) {
       // Create a timer with a maximum length.
-      this._timers.timeBetweenDraftActions = this.getTimer(
+      this.timers.timeBetweenDraftActions = this.getTimer(
         DRAFT_ACTION_TIMER
       ).withMaximum(DRAFT_ACTION_TIMER_MAX);
       return;
@@ -848,11 +848,11 @@
   }
 
   setRepoName(repoName: string) {
-    this._reportRepoName = repoName;
+    this.reportRepoName = repoName;
   }
 
   setChangeId(changeId: NumericChangeId) {
-    this._reportChangeId = changeId;
+    this.reportChangeId = changeId;
   }
 }
 
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index d4e6d52..c1989de 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -27,7 +27,7 @@
     <style>
       :host {
         --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
+          border: 1px solid var(--border-color);
           border-radius: 1em;
           box-shadow: none;
           box-sizing: border-box;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index c3b0681..18c12b0 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -76,8 +76,6 @@
     --info-background: var(--blue-50);
     --selected-foreground: var(--blue-700);
     --selected-background: var(--blue-50);
-    --info-deemphasized-foreground: var(--gray-300);
-    --info-deemphasized-background: var(--gray-50);
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
     --gray-foreground: var(--gray-700);
@@ -101,6 +99,7 @@
     --tooltip-text-color: white;
     --negative-red-text-color: #d93025;
     --positive-green-text-color: #188038;
+    --indirect-ancestor-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
@@ -171,7 +170,7 @@
     --line-height-mono: 1.286rem;   /* 18px */
     --line-height-small: 1.143rem;  /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h3: 1.715rem;     /* 24px */
     --line-height-h2: 2rem;         /* 28px */
     --line-height-h1: 2.286rem;     /* 32px */
     --font-weight-normal: 400; /* 400 is the same as 'normal' */
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 4057f7f..5455a24 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -44,9 +44,10 @@
       --warning-background: var(--orange-900);
       --info-foreground: var(--blue-200);
       --info-background: var(--blue-900);
-      --info-deemphasized-foreground: var(--gray-700);
-      --info-deemphasized-background: var(--primary-text-color);
+      --selected-foreground: var(--blue-200);
+      --selected-background: var(--blue-900);
       --success-foreground: var(--green-200);
+      --success-background: var(--green-900);
       --gray-foreground: var(--gray-100);
       --gray-background: var(--gray-900);
       --tag-background: var(--cyan-900);
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 0f394be..4e1662c 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -90,8 +90,10 @@
 }
 
 window.fixture = fixtureImpl;
+let testSetupTimestampMs = 0;
 
 setup(() => {
+  testSetupTimestampMs = new Date().getTime();
   window.Gerrit = {};
   initGlobalVariables();
   addIronOverlayBackdropStyleEl();
@@ -201,4 +203,9 @@
   // `this.debounce()`. For those please be careful and cancel them using
   // `this.cancelDebouncer()` in the `detached()` lifecycle hook.
   flushDebouncers();
+  const testTeardownTimestampMs = new Date().getTime();
+  const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
+  if (elapsedMs > 1000) {
+    console.warn(`ATTENTION! Test took longer than 1 second: ${elapsedMs} ms`);
+  }
 });
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 0d751ab..46d0173d 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -48,7 +48,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 46bd926..dfd2078 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -9,7 +9,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 1b6c226..9c2ff93 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -14,7 +14,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index cf7e37d..1bfabf8 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -76,6 +76,7 @@
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
 export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
 
 export const EditPatchSetNum = 'edit' as PatchSetNum;
 // TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
@@ -1021,7 +1022,7 @@
  */
 export interface PluginConfigInfo {
   has_avatars: boolean;
-  // The following 2 properies exists in Java class, but don't mention in docs
+  // The following 2 properties exists in Java class, but don't mention in docs
   js_resource_paths: string[];
   html_resource_paths: string[];
 }
@@ -1763,7 +1764,6 @@
   email_strategy: EmailStrategy;
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
-  disable_keyboard_shortcuts?: boolean;
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 6b05fad..5965453 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -19,6 +19,7 @@
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {MovedLinkClickedEventDetail} from '../api/diff';
+import {Category, RunStatus} from '../api/checks';
 
 export interface TitleChangeEventDetail {
   title: string;
@@ -152,6 +153,7 @@
 
 export interface TabState {
   commentTab?: CommentTabState;
+  checksTab?: ChecksTabState;
 }
 
 export enum CommentTabState {
@@ -160,6 +162,11 @@
   SHOW_ALL = 'show all',
 }
 
+export interface ChecksTabState {
+  statusOrCategory?: RunStatus | Category;
+  checkName?: string;
+}
+
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
 declare global {
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 1228863..a7f8b49 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -173,28 +173,44 @@
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
-export function isReviewer(change?: ChangeInfo, account?: AccountInfo) {
+export function isReviewer(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   const reviewers = change.reviewers.REVIEWER ?? [];
   return reviewers.some(r => r._account_id === account._account_id);
 }
 
-export function isUploader(change?: ChangeInfo, account?: AccountInfo) {
+export function isCc(change?: ChangeInfo, account?: AccountInfo): boolean {
+  if (!change || !account) return false;
+  const ccs = change.reviewers.CC ?? [];
+  return ccs.some(r => r._account_id === account._account_id);
+}
+
+export function isUploader(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   const rev = getCurrentRevision(change);
   return rev?.uploader?._account_id === account._account_id;
 }
 
-export function isInvolved(change?: ChangeInfo, account?: AccountInfo) {
+export function isInvolved(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   const owner = isOwner(change, account);
   const uploader = isUploader(change, account);
   const reviewer = isReviewer(change, account);
-  return owner || uploader || reviewer;
+  const cc = isCc(change, account);
+  return owner || uploader || reviewer || cc;
 }
 
 export function getCurrentRevision(change?: ChangeInfo) {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 61456a2..de12a2a 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -289,6 +289,8 @@
 }
 
 export function computeDiffFromContext(context: ContextLine[], path: string) {
+  // do not render more than 20 lines of context
+  context = context.slice(0, 20);
   const diff: DiffInfo = {
     meta_a: {
       name: '',
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index ad76b79..f4d6d51 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -67,6 +67,30 @@
 }
 
 /**
+ * Throws an error if the property is not defined.
+ */
+export function checkRequiredProperty<T>(
+  property: T,
+  propertyName: string
+): asserts property is NonNullable<T> {
+  if (property === undefined || property === null) {
+    throw new Error(`Required property '${propertyName}' not set.`);
+  }
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function assertIsDefined<T>(
+  val: T,
+  variableName = 'variable'
+): asserts val is NonNullable<T> {
+  if (val === undefined || val === null) {
+    throw new Error(`${variableName} is not defined`);
+  }
+}
+
+/**
  * Returns true, if both sets contain the same members.
  */
 export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
@@ -87,3 +111,14 @@
   }
   return true;
 }
+
+/**
+ * Add value, if the set does not contain it. Otherwise remove it.
+ */
+export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+  if (set.has(value)) {
+    set.delete(value);
+  } else {
+    set.add(value);
+  }
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 917d652b..d6d66d7 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -29,7 +29,7 @@
       assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
-    test('object prototype has overriden hasOwnProperty', () => {
+    test('object prototype has overridden hasOwnProperty', () => {
       const F = function() {
         this.abc = 23;
       };
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index fad5041..3af8c59 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -37,9 +37,13 @@
 
 // similar to fromNow from moment.js
 export function fromNow(date: Date, noAgo = false) {
-  const now = new Date();
+  return durationString(date, new Date(), noAgo);
+}
+
+// similar to fromNow from moment.js
+export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.floor((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index aa83173..7f9ef72 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -16,6 +16,7 @@
  */
 
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {check} from './common-util';
 
 /**
  * Event emitted from polymer elements.
@@ -155,7 +156,7 @@
 
 export function windowLocationReload() {
   const e = new Error();
-  console.info(`Calling window.location.realod(): ${e.stack}`);
+  console.info(`Calling window.location.reload(): ${e.stack}`);
   window.location.reload();
 }
 
@@ -258,3 +259,22 @@
     (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
   );
 }
+
+export function whenVisible(
+  element: Element,
+  callback: () => void,
+  marginPx = 0
+) {
+  const observer = new IntersectionObserver(
+    (entries: IntersectionObserverEntry[]) => {
+      check(entries.length === 1, 'Expected one intersection observer entry.');
+      const entry = entries[0];
+      if (entry.isIntersecting) {
+        observer.unobserve(entry.target);
+        callback();
+      }
+    },
+    {rootMargin: `${marginPx}px`}
+  );
+  observer.observe(element);
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 60ac4d8..4eed0a0 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -51,3 +51,11 @@
 ): ApprovalInfo | undefined {
   return label.all?.filter(x => x._account_id === account._account_id)[0];
 }
+
+export function labelCompare(labelName1: string, labelName2: string) {
+  if (labelName1 === CODE_REVIEW && labelName2 === CODE_REVIEW) return 0;
+  if (labelName1 === CODE_REVIEW) return -1;
+  if (labelName2 === CODE_REVIEW) return 1;
+
+  return labelName1.localeCompare(labelName2);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
index 6a2f768..f9a30df 100644
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -21,6 +21,7 @@
   getVotingRangeOrDefault,
   getMaxAccounts,
   getApprovalInfo,
+  labelCompare,
 } from './label-util.js';
 
 const VALUES_1 = {
@@ -113,4 +114,11 @@
     };
     assert.isUndefined(getApprovalInfo(label, myAccountInfo));
   });
+
+  test('labelCompare', () => {
+    let sorted = ['c', 'b', 'a'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
+    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
+  });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 40e3eef..af56798 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -3,11 +3,12 @@
   ChangeInfo,
   PatchSetNum,
   EditPatchSetNum,
-  BrandType,
   ParentPatchSetNum,
+  PatchSetNumber,
 } from '../types/common';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {check} from './common-util';
 
 /**
  * @license
@@ -82,9 +83,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(
-  psn: PatchSetNum
-): psn is BrandType<number, '_patchSet'> {
+export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
 
@@ -250,14 +249,16 @@
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
-): PatchSetNum | undefined {
+): PatchSetNumber | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
-  if (allPatchSets[0].num === EditPatchSetNum) {
-    return allPatchSets[1].num;
+  let latest = allPatchSets[0].num;
+  if (latest === EditPatchSetNum) {
+    latest = allPatchSets[1].num;
   }
-  return allPatchSets[0].num;
+  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  return latest;
 }
 
 export function computePredecessor(
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
index 0658be3..b1b17f4 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -25,15 +25,15 @@
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originialCanonicalPath;
+    let originalCanonicalPath;
 
     suiteSetup(() => {
-      originialCanonicalPath = window.CANONICAL_PATH;
+      originalCanonicalPath = window.CANONICAL_PATH;
       window.CANONICAL_PATH = '/r';
     });
 
     suiteTeardown(() => {
-      window.CANONICAL_PATH = originialCanonicalPath;
+      window.CANONICAL_PATH = originalCanonicalPath;
     });
 
     test('getBaseUrl', () => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index e544dbc..ec3b7a0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -324,6 +324,11 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resize-observer-browser@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
+  integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
+
 "@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 0aeb8d5..22ee330 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -25,7 +25,7 @@
 # this higher can make builds faster by allowing more jobs to run in parallel.
 # Setting it too high can result in jobs that timeout, however, while waiting
 # for a remote machine to execute them.
-build:remote --jobs=50
+build:remote --jobs=100
 build:remote --disk_cache=
 
 # Set several flags related to specifying the platform, toolchain and java
@@ -42,9 +42,6 @@
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
 # machine exactly match the host machine.
-build:remote --spawn_strategy=remote,sandboxed
-build:remote --strategy=Javac=remote
-build:remote --strategy=Genrule=remote
 build:remote --define=EXECUTOR=remote
 
 # Enable the remote cache so action results can be shared across machines,
@@ -68,6 +65,3 @@
 build:remote-cache --tls_enabled=true
 build:remote-cache --remote_timeout=3600
 build:remote-cache --auth_enabled=true
-build:remote-cache --spawn_strategy=standalone
-build:remote-cache --strategy=Javac=standalone
-build:remote-cache --strategy=Genrule=standalone