Merge "Revert "Fix bug about persisting copied votes on submit""
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 274fbb0..e39d091 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -88,8 +88,8 @@
 evolves, such as "Added more unit tests." Unlike the change description, a patch
 set description does not become a part of the project's history.
 
-To add a patch set description, click *Add a patch set description*, located in
-the file list, or provide it link:user-upload.html#patch_set_description[on upload].
+To add a patch set description provide it
+link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index fa2b78c..e367b07 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2712,6 +2712,23 @@
 prevent callers using ETags from potentially seeing outdated submittability
 information.
 
+`SubmitRule` interface will soon deprecated. Instead, a global `SubmitRequirement`
+can be bound by plugin.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(SubmitRequirement.class).annotatedWith(Exports.named("myPlugin"))
+        .toInstance(myPluginSubmitRequirement);
+  }
+}
+----
+
 [[change-etag-computation]]
 == Change ETag Computation
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index c62350d..870e194 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1857,7 +1857,8 @@
 
 * The given change.
 * If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-  is enabled, include all open changes with the same topic.
+  is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+  open changes with the same topic.
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
@@ -1884,7 +1885,7 @@
 
 Standard link:#query-options[formatting options] can be specified
 with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
 
 .Response
 ----
@@ -4581,60 +4582,6 @@
 If the `path` parameter is set, the returned content is a diff of the single
 file that the path refers to.
 
-[[submit-preview]]
-=== Submit Preview
---
-'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
---
-Gets a file containing thin bundles of all modified projects if this
-change was submitted. The bundles are named `${ProjectName}.git`.
-Each thin bundle contains enough to construct the state in which a project would
-be in if this change were submitted. The base of the thin bundles are the
-current target branches, so to make use of this call in a non-racy way, first
-get the bundles and then fetch all projects contained in the bundle.
-(This assumes no non-fastforward pushes).
-
-You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container. It is always possible to use tgz, even if
-tgz is not in the list of allowed archive formats.
-
-To make good use of this call, you would roughly need code as found at:
-----
- $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
-----
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Date: Tue, 13 Sep 2016 19:13:46 GMT
-  Content-Disposition: attachment; filename="submit-preview-147.zip"
-  X-Content-Type-Options: nosniff
-  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
-  Pragma: no-cache
-  Expires: Mon, 01 Jan 1990 00:00:00 GMT
-  Content-Type: application/x-zip
-  Transfer-Encoding: chunked
-
-  [binary stuff]
-----
-
-In case of an error, the response is not a zip file but a regular json response,
-containing only the error message:
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Anonymous users cannot submit"
-----
-
 [[get-mergeable]]
 === Get Mergeable
 --
diff --git a/java/Main.java b/java/Main.java
index 11d8234..09c8c76 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@SuppressWarnings("DefaultPackage")
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index e31a6ce..2fb2127 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -41,6 +42,7 @@
 import com.google.common.base.Ticker;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.jimfs.Jimfs;
@@ -80,6 +82,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -1203,9 +1206,37 @@
   }
 
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+  }
+
+  protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+      throws Exception {
+    assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+  }
+
+  protected void assertSubmittedTogether(
+      String chId,
+      ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+      String... expected)
+      throws Exception {
+    // This does not include NON_VISIBILE_CHANGES
+    List<ChangeInfo> actual =
+        submittedTogetherOptions.isEmpty()
+            ? gApi.changes().id(chId).submittedTogether()
+            : gApi.changes()
+                .id(chId)
+                .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+                .changes;
+
+    EnumSet enumSetIncludingNonVisibleChanges =
+        submittedTogetherOptions.isEmpty()
+            ? EnumSet.of(NON_VISIBLE_CHANGES)
+            : EnumSet.copyOf(submittedTogetherOptions);
+    enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+    // This includes NON_VISIBLE_CHANGES for comparison.
     SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(Iterables.transform(actual, i1 -> i1.changeId))
@@ -1259,12 +1290,6 @@
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
-      return fetchFromBundles(result);
-    }
-  }
-
   /**
    * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
    * resulting tree id.
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 1e5598e..a1dc9e3 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -70,6 +71,7 @@
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   private final DynamicSet<SubmitRule> submitRules;
+  private final DynamicSet<SubmitRequirement> submitRequirements;
   private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   private final DynamicSet<ChangeETagComputation> changeETagComputations;
   private final DynamicSet<ActionVisitor> actionVisitors;
@@ -108,6 +110,7 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       DynamicSet<SubmitRule> submitRules,
+      DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
       DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
@@ -142,6 +145,7 @@
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.submitRules = submitRules;
+    this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
     this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
@@ -216,6 +220,10 @@
       return add(submitRules, submitRule);
     }
 
+    public Registration add(SubmitRequirement submitRequirement) {
+      return add(submitRequirements, submitRequirement);
+    }
+
     public Registration add(ChangeMessageModifier changeMessageModifier) {
       return add(changeMessageModifiers, changeMessageModifier);
     }
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 3b0ba3b..debd9d8 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -168,6 +168,7 @@
                 MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
               } catch (IOException e) {
                 e.printStackTrace();
+                throw new RuntimeException("Failed to cleanup userhome", e);
               }
             });
       }
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 37f6c2c..09a8993 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -32,16 +32,27 @@
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
+      // We cannot propagate the exception since this code is running in a background thread.
+      // Printing the stacktrace is the best we can do. Hence ignoring the exception after printing
+      // the stacktrace is OK and it's fine to suppress the warning for the CatchAndPrintStackTrace
+      // bug pattern here.
+      @SuppressWarnings("CatchAndPrintStackTrace")
       @Override
       public void run() {
         try {
+          copyIo();
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+      }
+
+      private void copyIo() throws IOException {
+        try {
           final byte[] buf = new byte[256];
           int n;
           while (0 < (n = src.read(buf))) {
             dst.write(buf, 0, n);
           }
-        } catch (IOException e) {
-          e.printStackTrace();
         } finally {
           try {
             src.close();
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 13e0b53..3f91cc7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -15,11 +15,23 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
-/** Entity describing a requirement that should be met for a change to become submittable. */
+/**
+ * Entity describing a requirement that should be met for a change to become submittable.
+ *
+ * <p>There are two ways to contribute {@link SubmitRequirement}:
+ *
+ * <ul>
+ *   <li>Set per-project in project.config (see {@link
+ *       com.google.gerrit.server.project.ProjectState#getSubmitRequirements()}
+ *   <li>Bind a global {@link SubmitRequirement} that will be evaluated for all projects.
+ * </ul>
+ */
+@ExtensionPoint
 @AutoValue
 public abstract class SubmitRequirement {
   /** Requirement name. */
@@ -56,7 +68,12 @@
 
   /**
    * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
-   * projects. Default is false.
+   * projects.
+   *
+   * <p>For globally bound {@link SubmitRequirement}, indicates if can be overridden by {@link
+   * SubmitRequirement} in project.config.
+   *
+   * <p>Default is false.
    */
   public abstract boolean allowOverrideInChildProjects();
 
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..b659cca 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -51,12 +51,6 @@
 
   ChangeInfo submit(SubmitInput in) throws RestApiException;
 
-  default BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  BinaryResult submitPreview(String format) throws RestApiException;
-
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
@@ -369,11 +363,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview(String format) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES;
+  NON_VISIBLE_CHANGES,
+  TOPIC_CLOSURE;
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 634992e..787508ab 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -110,6 +110,11 @@
     return 1;
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the extension API, hence we cannot change it
+  // without breaking the API. Hence suppress the EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index bf72e83..a4e0baa 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -88,6 +88,11 @@
   }
 
   @Override
+  public String toString() {
+    return super.toString() + ", value=" + this.value;
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
   }
diff --git a/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
index df3f373..6ee677e 100644
--- a/java/com/google/gerrit/extensions/common/BlameInfo.java
+++ b/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -28,7 +28,7 @@
     if (this == o) {
       return true;
     }
-    if (o == null || getClass() != o.getClass()) {
+    if (o == null || !(o instanceof BlameInfo)) {
       return false;
     }
     BlameInfo blameInfo = (BlameInfo) o;
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..ce22ae8 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -97,7 +97,8 @@
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
+        data.put(
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 0e54b0a..8395d12 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -104,12 +104,6 @@
           ListChangesOption.SKIP_DIFFSTAT,
           ListChangesOption.SUBMIT_REQUIREMENTS);
 
-  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.SKIP_DIFFSTAT);
-
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
       return null;
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index ae13fb3..fd8ca96 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -105,6 +105,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index d9e33ea..ffd442b 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -112,6 +112,8 @@
     return pred.hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null || getClass() != other.getClass()) {
diff --git a/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
index 16e59e7..a98e0b1 100644
--- a/java/com/google/gerrit/index/query/IntPredicate.java
+++ b/java/com/google/gerrit/index/query/IntPredicate.java
@@ -37,6 +37,8 @@
     return getOperator().hashCode() * 31 + intValue;
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 14cb740..75e37b7 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -21,7 +21,7 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+public final class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
   protected NotPredicate(Predicate<T> that) {
@@ -87,7 +87,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
+    return other instanceof NotPredicate
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
diff --git a/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
index 368ee24..ea7717f 100644
--- a/java/com/google/gerrit/index/query/OperatorPredicate.java
+++ b/java/com/google/gerrit/index/query/OperatorPredicate.java
@@ -47,6 +47,8 @@
     return getOperator().hashCode() * 31 + getValue().hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 9bc3769..1c31af3 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -105,6 +105,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 2809a14..ce433d7 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -204,6 +205,9 @@
     modules.add(new DefaultSubmitRuleModule());
     modules.add(new IgnoreSelfApprovalRuleModule());
 
+    // Global submit requirements
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
+
     bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
     bind(EventUtil.class).toProvider(Providers.of(null));
     bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
index 773a307..54bbe23 100644
--- a/java/com/google/gerrit/server/AuditEvent.java
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -82,6 +82,12 @@
     return uuid.hashCode();
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the plugin API (used in the AuditListener
+  // extension point), hence we cannot change it without breaking plugins. Hence suppress the
+  // EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7080417..9470931 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -144,6 +144,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..51c5ecd 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -64,7 +64,7 @@
 
   /** Returns true if the account is backed by the realm, false otherwise. */
   default boolean accountBelongsToRealm(
-      @SuppressWarnings("unused") Collection<ExternalId> externalIds) {
+      @SuppressWarnings("unused") Collection<ExternalId> externalIds) throws IOException {
     return false;
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..9aa9306 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -86,7 +86,6 @@
 import com.google.gerrit.server.restapi.change.ListRobotComments;
 import com.google.gerrit.server.restapi.change.Mergeable;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.PreviewSubmit;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +118,6 @@
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
-  private final PreviewSubmit submitPreview;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
@@ -167,7 +165,6 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
-      PreviewSubmit submitPreview,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
@@ -213,7 +210,6 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
-    this.submitPreview = submitPreview;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
@@ -270,16 +266,6 @@
   }
 
   @Override
-  public BinaryResult submitPreview(String format) throws RestApiException {
-    try {
-      submitPreview.setFormat(format);
-      return submitPreview.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit preview", e);
-    }
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebaseAsInfo(in)._number);
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e57238b..4114f64 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -790,7 +790,7 @@
   }
 
   private boolean submittable(ChangeData cd) {
-    return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
+    return cd.submitRequirements().values().stream().allMatch(srResult -> srResult.fulfilled());
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7d0bda1..2b0d512 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -95,7 +95,7 @@
   public abstract static class Result {
     private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
       return new AutoValue_ConsistencyChecker_Result(
-          notes.getChangeId(), notes.getChange(), problems);
+          notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems));
     }
 
     public abstract Change.Id id();
@@ -103,7 +103,7 @@
     @Nullable
     public abstract Change change();
 
-    public abstract List<ProblemInfo> problems();
+    public abstract ImmutableList<ProblemInfo> problems();
   }
 
   private final ChangeNotes.Factory notesFactory;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 7878b34..7d3ff12 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Ticker;
 import com.google.common.cache.Cache;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -434,6 +435,7 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
     DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index c54ab25..4d2805d 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,6 +19,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,12 +39,13 @@
   @VisibleForTesting
   @AutoValue
   public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
+    public abstract ImmutableMap<String, Ref> allRefs();
 
-    public abstract Set<ObjectId> additionalHaves();
+    public abstract ImmutableSet<ObjectId> additionalHaves();
 
     public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
-      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+      return new AutoValue_TestRefAdvertiser_Result(
+          ImmutableMap.copyOf(allRefs), ImmutableSet.copyOf(additionalHaves));
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..b8b8a2c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -61,7 +61,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(2)
+            .version(3)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5cf3a64..a3a2b4b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,11 +40,13 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static java.util.Comparator.comparing;
+import static java.util.Comparator.comparingInt;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
@@ -70,6 +72,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -83,6 +86,7 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -95,6 +99,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -312,10 +317,81 @@
       }
       result.put(a.key().patchSetId(), a.build());
     }
+    if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
+      // If the change is closed, check if there are "submit records" with approvals that do not
+      // exist on the latest patch-set and copy them to the latest patch-set.
+      // We do not invoke this logic if any approval is copied. This is because prior to change
+      // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
+      // dynamically (e.g. when requesting the change page). After that change, we started
+      // persisting copied votes in NoteDb, so we don't need to do this back-filling.
+      // Prior to that change (318135), we could've had changes with dynamically copied approvals
+      // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
+      // we need to back-fill these approvals.
+      PatchSet.Id latestPs = buildCurrentPatchSetId();
+      backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
+          .forEach(a -> result.put(latestPs, a));
+    }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
+  /**
+   * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
+   * record exists in NoteDb when the change was merged.
+   */
+  private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
+    List<PatchSetApproval> copiedApprovals = new ArrayList<>();
+    if (latestPs == null) {
+      return copiedApprovals;
+    }
+    List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
+    ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+    List<SubmitRecord.Label> submitRecordLabels =
+        submitRecords.stream()
+            .filter(r -> r.labels != null)
+            .flatMap(r -> r.labels.stream())
+            .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
+            .collect(Collectors.toList());
+    for (SubmitRecord.Label recordLabel : submitRecordLabels) {
+      String labelName = recordLabel.label;
+      Account.Id appliedBy = recordLabel.appliedBy;
+      if (appliedBy == null || labelName == null) {
+        continue;
+      }
+      boolean existsAtLatestPs =
+          approvalsOnLatestPs.stream()
+              .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
+      if (existsAtLatestPs) {
+        continue;
+      }
+      // Search for an approval for this label on the max previous patch-set and copy the approval.
+      Collection<PatchSetApproval> userApprovals =
+          approvalsByUser.get(appliedBy).stream()
+              .filter(approval -> approval.label().equals(labelName))
+              .collect(Collectors.toList());
+      if (userApprovals.isEmpty()) {
+        continue;
+      }
+      PatchSetApproval lastApproved =
+          Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
+      copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
+    }
+    return copiedApprovals;
+  }
+
+  private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream().anyMatch(approval -> approval.copied());
+  }
+
+  private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream()
+        .collect(
+            ImmutableListMultimap.toImmutableListMultimap(
+                PatchSetApproval::accountId, Function.identity()));
+  }
+
   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 10aa9cd..c8a6f60 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -149,7 +149,7 @@
     // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
     Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts);
-    List<Ref> visibleRefs = initialRefFilter.visibleRefs();
+    List<Ref> visibleRefs = new ArrayList<>(initialRefFilter.visibleRefs());
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
         Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
@@ -198,13 +198,15 @@
         skipFilterCount.increment();
         logger.atFinest().log(
             "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         skipFilterCount.increment();
         refs = fastHideRefsMetaConfig(refs);
         logger.atFinest().log(
             "Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       }
     }
     logger.atFinest().log("Doing full ref filtering");
@@ -263,7 +265,9 @@
         resultRefs.add(ref);
       }
     }
-    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    Result result =
+        new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(resultRefs), ImmutableList.copyOf(deferredTags));
     logger.atFinest().log("Result of ref filtering = %s", result);
     return result;
   }
@@ -400,12 +404,12 @@
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
-    abstract List<Ref> visibleRefs();
+    abstract ImmutableList<Ref> visibleRefs();
 
     /**
      * List of tags where we couldn't figure out visibility in the first pass and need to do an
      * expensive ref walk.
      */
-    abstract List<Ref> deferredTags();
+    abstract ImmutableList<Ref> deferredTags();
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index e64f8b6..621f1d0 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -125,7 +125,7 @@
   abstract static class EntryKey {
     public abstract String ref();
 
-    public abstract List<String> patterns();
+    public abstract ImmutableList<String> patterns();
 
     static EntryKey create(String refName, List<AccessSection> sections) {
       List<String> patterns = new ArrayList<>(sections.size());
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index dbe96cb..83c4bab7 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.MoreCollectors;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.SubmitRecord;
@@ -48,11 +47,11 @@
    * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
    */
   public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
-      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+      ChangeData cd) {
     // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
     // This doesn't have an effect since we never call this class (i.e. to evaluate submit
     // requirements) for closed changes.
-    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<SubmitRecord> records = cd.submitRecords(SubmitRuleOptions.defaults());
     List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
     ObjectId commitId = cd.currentPatchSet().commitId();
     return records.stream()
@@ -95,7 +94,13 @@
       List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
     for (Label label : labels) {
-      LabelType labelType = getLabelType(labelTypes, label.label);
+      Optional<LabelType> maybeLabelType = getLabelType(labelTypes, label.label);
+      if (!maybeLabelType.isPresent()) {
+        // Label type might have been removed from the project config. We don't have information
+        // if it was blocking or not, hence we skip the label.
+        continue;
+      }
+      LabelType labelType = maybeLabelType.get();
       if (!isBlocking(labelType)) {
         continue;
       }
@@ -230,9 +235,19 @@
         status == Status.FAIL ? atoms : ImmutableList.of());
   }
 
-  private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
-    return labelTypes.stream()
-        .filter(lt -> lt.getName().equals(labelName))
-        .collect(MoreCollectors.onlyElement());
+  private static Optional<LabelType> getLabelType(List<LabelType> labelTypes, String labelName) {
+    List<LabelType> label =
+        labelTypes.stream()
+            .filter(lt -> lt.getName().equals(labelName))
+            .collect(Collectors.toList());
+    if (label.isEmpty()) {
+      // Label might have been removed from the project.
+      logger.atFine().log("Label '%s' was not found for the project.", labelName);
+      return Optional.empty();
+    } else if (label.size() > 1) {
+      logger.atWarning().log("Found more than one label definition for label name '%s'", labelName);
+      return Optional.empty();
+    }
+    return Optional.of(label.get(0));
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index cc2c805..4d081d7 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
@@ -24,6 +25,7 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
 import com.google.inject.AbstractModule;
@@ -34,13 +36,15 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
 
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
-  private final SubmitRuleEvaluator.Factory legacyEvaluator;
+  private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
 
   public static Module module() {
     return new AbstractModule() {
@@ -57,10 +61,10 @@
   private SubmitRequirementsEvaluatorImpl(
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
-      SubmitRuleEvaluator.Factory legacyEvaluator) {
+      PluginSetContext<SubmitRequirement> globalSubmitRequirements) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
-    this.legacyEvaluator = legacyEvaluator;
+    this.globalSubmitRequirements = globalSubmitRequirements;
   }
 
   @Override
@@ -76,7 +80,7 @@
     Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
     if (includeLegacy) {
       Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-          SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
+          SubmitRequirementsAdapter.getLegacyRequirements(cd);
       result =
           SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
               projectConfigRequirements, legacyReqs);
@@ -121,15 +125,52 @@
     }
   }
 
-  /** Evaluate and return submit requirements stored in this project's config and its parents. */
+  /**
+   * Evaluate and return all {@link SubmitRequirement}s.
+   *
+   * <p>This includes all globally bound {@link SubmitRequirement}s, as well as requirements stored
+   * in this project's config and its parents.
+   *
+   * <p>The behaviour in case of the name match is controlled by {@link
+   * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
+   */
   private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+    Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+
     ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
-    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+
+    ImmutableMap<String, SubmitRequirement> requirements =
+        Stream.concat(
+                globalRequirements.entrySet().stream(),
+                projectConfigRequirements.entrySet().stream())
+            .collect(
+                toImmutableMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    (globalSubmitRequirement, projectConfigRequirement) ->
+                        // Override with projectConfigRequirement if allowed by
+                        // globalSubmitRequirement configuration
+                        globalSubmitRequirement.allowOverrideInChildProjects()
+                            ? projectConfigRequirement
+                            : globalSubmitRequirement));
+    Map<SubmitRequirement, SubmitRequirementResult> results = new HashMap<>();
     for (SubmitRequirement requirement : requirements.values()) {
-      result.put(requirement, evaluateRequirement(requirement, cd));
+      results.put(requirement, evaluateRequirement(requirement, cd));
     }
-    return result;
+    return results;
+  }
+
+  /**
+   * Returns a map of all global {@link SubmitRequirement}s, keyed by their lower-case name.
+   *
+   * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
+   */
+  private Map<String, SubmitRequirement> getGlobalRequirements() {
+    return globalSubmitRequirements.stream()
+        .collect(
+            toImmutableMap(
+                globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
   }
 
   /** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index e38ff3d..f3a8a9d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -424,6 +424,10 @@
     return this;
   }
 
+  public StorageConstraint getStorageConstraint() {
+    return storageConstraint;
+  }
+
   /** Returns {@code true} if we allow reading data from NoteDb. */
   public boolean lazyload() {
     return storageConstraint.ordinal()
@@ -948,10 +952,6 @@
    * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
-      return Collections.emptyMap();
-    }
     if (submitRequirements == null) {
       if (!lazyload()) {
         return Collections.emptyMap();
@@ -969,7 +969,7 @@
               .filter(r -> !r.isLegacy())
               .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
       Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
-          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
+          SubmitRequirementsAdapter.getLegacyRequirements(this);
       submitRequirements =
           SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
               projectConfigRequirements, legacyRequirements);
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index b2bc6aa..a65d0a0 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
 import java.util.Optional;
 
 public class EqualsLabelPredicate extends ChangeIndexPredicate {
@@ -100,6 +101,7 @@
 
     boolean hasVote = false;
     int matchingVotes = 0;
+    StorageConstraint currentStorageConstraint = object.getStorageConstraint();
     object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
@@ -109,7 +111,7 @@
         }
       }
     }
-
+    object.setStorageConstraint(currentStorageConstraint);
     if (!hasVote && expVal == 0) {
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 02b4c13..e09f2f4 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -143,7 +143,6 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
-    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
     put(REVISION_KIND, "description").to(PutDescription.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
deleted file mode 100644
index 4acf809..0000000
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2016 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.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormatInternal;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.submit.MergeOp;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-public class PreviewSubmit implements RestReadView<RevisionResource> {
-  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
-  private final Provider<MergeOp> mergeOpProvider;
-  private final AllowedFormats allowedFormats;
-  private int maxBundleSize;
-  private String format;
-
-  @Option(name = "--format")
-  public void setFormat(String f) {
-    this.format = f;
-  }
-
-  @Inject
-  PreviewSubmit(
-      Provider<MergeOp> mergeOpProvider,
-      AllowedFormats allowedFormats,
-      @GerritServerConfig Config cfg) {
-    this.mergeOpProvider = mergeOpProvider;
-    this.allowedFormats = allowedFormats;
-    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
-  }
-
-  @Override
-  public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
-    if (f == null && format.equals("tgz")) {
-      // Always allow tgz, even when the allowedFormats doesn't contain it.
-      // Then we allow at least one format even if the list of allowed
-      // formats is empty.
-      f = ArchiveFormatInternal.TGZ;
-    }
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-
-    Change change = rsrc.getChange();
-    if (!change.isNew()) {
-      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
-    }
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new MethodNotAllowedException("Anonymous users cannot submit");
-    }
-
-    return Response.ok(getBundles(rsrc, f));
-  }
-
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    Change change = rsrc.getChange();
-
-    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
-    MergeOp op = mergeOpProvider.get();
-    try {
-      op.merge(change, caller, false, new SubmitInput(), true);
-      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
-      return bin;
-    } catch (RestApiException
-        | UpdateException
-        | IOException
-        | ConfigInvalidException
-        | RuntimeException
-        | PermissionBackendException e) {
-      op.close();
-      throw e;
-    }
-  }
-
-  private static class SubmitPreviewResult extends BinaryResult {
-
-    private final MergeOp mergeOp;
-    private final ArchiveFormatInternal archiveFormat;
-    private final int maxBundleSize;
-
-    private SubmitPreviewResult(
-        MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
-      this.mergeOp = mergeOp;
-      this.archiveFormat = archiveFormat;
-      this.maxBundleSize = maxBundleSize;
-    }
-
-    @Override
-    public void writeTo(OutputStream out) throws IOException {
-      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
-        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
-        for (Project.NameKey p : mergeOp.getAllProjects()) {
-          OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
-          bw.setObjectCountCallback(null);
-          bw.setPackConfig(new PackConfig(or.getRepo()));
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
-          for (ReceiveCommand r : refs) {
-            bw.include(r.getRefName(), r.getNewId());
-            ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())
-                // Probably the client doesn't already have NoteDb data.
-                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
-              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
-            }
-          }
-          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
-          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
-          // This naming scheme cannot produce directory/file conflicts
-          // as no projects contains ".git/":
-          String path = p.get() + ".git";
-          archiveFormat.putEntry(aos, path, bos.toByteArray());
-        }
-      } catch (LimitExceededException e) {
-        throw new NotImplementedException("The bundle is too big to generate at the server", e);
-      } catch (NoSuchProjectException e) {
-        throw new IOException(e);
-      }
-    }
-
-    @Override
-    public void close() throws IOException {
-      mergeOp.close();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 5155a0d..154e45a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -304,7 +304,10 @@
       return null; // submit not visible
     }
 
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..c18e7c2 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static java.util.Collections.reverseOrder;
 import static java.util.stream.Collectors.toList;
 
@@ -127,7 +128,10 @@
       int hidden;
 
       if (c.isNew()) {
-        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+        ChangeSet cs =
+            mergeSuperSet
+                .get()
+                .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.isMerged()) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 64b60bb..b431299 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -480,7 +480,9 @@
       logger.atFine().log("Beginning integration of %s", change);
       try {
         ChangeSet indexBackedChangeSet =
-            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+            mergeSuperSet
+                .setMergeOpRepoManager(orm)
+                .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
         if (!indexBackedChangeSet.ids().contains(change.getId())) {
           // indexBackedChangeSet contains only open changes, if the change is missing in this set
           // it might be that the change was concurrently submitted in the meantime.
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
     return this;
   }
 
-  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+  /**
+   * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+   * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+   * of the topic closure. Otherwise, we return just the dependent changes.
+   *
+   * @param change the change for which we get the dependent changes / topic closure.
+   * @param user the current user for visibility purposes.
+   * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+   *     return the topic closure.
+   * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+   *     the requested change.
+   */
+  public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
       throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
@@ -113,7 +125,7 @@
       }
 
       ChangeSet changeSet = new ChangeSet(cd, visible);
-      if (wholeTopicEnabled(cfg)) {
+      if (wholeTopicEnabled(cfg) || includingTopicClosure) {
         return completeChangeSetIncludingTopics(changeSet, user);
       }
       try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 70d39b3..dc8f713 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ChangeStatus.MERGED;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -2838,6 +2840,53 @@
   }
 
   @Test
+  public void labelPermissionsChange_doesNotAffectCurrentVotes() throws Exception {
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Approve the change as user
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    assertThat(
+            gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2));
+
+    // Remove permissions for CODE_REVIEW. The user still has [-1,+1], inherited from All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS))
+        .update();
+
+    // No permissions to vote +2
+    assertThrows(AuthException.class, () -> approve(changeId));
+
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .map(vote -> vote.value))
+        .containsExactly(2);
+
+    // The change is still submittable
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(info(changeId).status).isEqualTo(MERGED);
+
+    // The +2 vote out of permissions range is still present.
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2, admin.id(), 0));
+  }
+
+  @Test
   public void createEmptyChange() throws Exception {
     ChangeInput in = new ChangeInput();
     in.branch = Constants.MASTER;
@@ -3089,7 +3138,7 @@
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
     gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3115,7 +3164,7 @@
         .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3554,7 +3603,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
@@ -3717,7 +3766,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet())
         .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
@@ -3734,7 +3783,7 @@
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
@@ -3751,11 +3800,11 @@
     result.assertOkStatus();
 
     ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
-    assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(firstChange.status).isEqualTo(MERGED);
     assertThat(firstChange.submissionId).isNotNull();
 
     ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
-    assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(secondChange.status).isEqualTo(MERGED);
     assertThat(secondChange.submissionId).isNotNull();
 
     assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
@@ -4166,9 +4215,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
@@ -4195,9 +4241,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
     configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
     projectOperations
@@ -4239,9 +4282,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
     configSubmitRequirement(
         project,
@@ -4283,9 +4323,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
     configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
     projectOperations
@@ -4335,9 +4372,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsAny() throws Exception {
     configSubmitRequirement(
         project,
@@ -4367,9 +4401,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
       throws Exception {
     configSubmitRequirement(
@@ -4408,9 +4439,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
       throws Exception {
     configSubmitRequirement(
@@ -4434,9 +4462,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
     configLabel("build-cop-override", LabelFunction.NO_BLOCK);
     projectOperations
@@ -4476,9 +4501,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overriddenInChildProjectWithStricterRequirement() throws Exception {
     configSubmitRequirement(
         allProjects,
@@ -4520,9 +4542,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overriddenInChildProjectWithLessStrictRequirement()
       throws Exception {
     configSubmitRequirement(
@@ -4563,9 +4582,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overriddenInChildProjectAsDisabled() throws Exception {
     configSubmitRequirement(
         allProjects,
@@ -4600,9 +4616,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_inheritedFromParentProject() throws Exception {
     configSubmitRequirement(
         allProjects,
@@ -4631,9 +4644,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overriddenSRInParentProjectIsInheritedByChildProject()
       throws Exception {
     // Define submit requirement in root project.
@@ -4679,9 +4689,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
       throws Exception {
     configSubmitRequirement(
@@ -4723,9 +4730,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_ignoredInChildProject_ifParentAddsSRThatDoesNotAllowOverride()
       throws Exception {
     // Submit requirement in child project (requires Code-Review=+1)
@@ -4779,9 +4783,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_ignoredInChildProject_ifParentMakesSRNonOverridable()
       throws Exception {
     configSubmitRequirement(
@@ -4843,9 +4844,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_ignoredInGrandChildProject_ifGrandParentDoesNotAllowOverride()
       throws Exception {
     configSubmitRequirement(
@@ -4891,9 +4889,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overrideOverideExpression() throws Exception {
     // Define submit requirement in root project.
     configSubmitRequirement(
@@ -4953,9 +4948,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
     // Create build-cop-override label
     LabelDefinitionInput input = new LabelDefinitionInput();
@@ -5046,11 +5038,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void submitRequirement_storedForClosedChanges() throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       Project.NameKey project = createProjectForPush(submitType);
@@ -5094,11 +5084,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void submitRequirement_storedForAbandonedChanges() throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       Project.NameKey project = createProjectForPush(submitType);
@@ -5137,11 +5125,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       Project.NameKey project = createProjectForPush(submitType);
@@ -5219,11 +5205,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
     configSubmitRequirement(
         project,
@@ -5269,11 +5253,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void
       submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
           throws Exception {
@@ -5334,9 +5316,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void
       submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
           throws Exception {
@@ -5390,9 +5369,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
     configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
     projectOperations
@@ -5445,9 +5421,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
     configSubmitRequirement(
         project,
@@ -5480,11 +5453,9 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
   public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
     configSubmitRequirement(
         project,
@@ -5516,9 +5487,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
     configSubmitRequirement(
         project,
@@ -5545,38 +5513,6 @@
   }
 
   @Test
-  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "Code-Review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirements_eliminatesDuplicatesForLegacyNonMatchingSRs() throws Exception {
     // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
     // a single submit requirement result: in this test, we have two different submit rules that
@@ -5600,9 +5536,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirements_eliminatesDuplicatesForLegacyMatchingSRs() throws Exception {
     // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
     // a single submit requirement result: in this test, we have two different submit rules that
@@ -5626,9 +5559,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirements_eliminatesMultipleDuplicatesForLegacyMatchingSRs()
       throws Exception {
     // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
@@ -5655,9 +5585,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_duplicateSubmitRequirement_sameCase() throws Exception {
     // Define 2 submit requirements with exact same name but different submittability expression.
     try (TestRepository<Repository> repo =
@@ -5708,9 +5635,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_duplicateSubmitRequirement_differentCase() throws Exception {
     // Define 2 submit requirements with same name but different case and different submittability
     // expression.
@@ -5767,9 +5691,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overrideInheritedSRWithDifferentNameCasing() throws Exception {
     // Define submit requirement in root project and allow override.
     configSubmitRequirement(
@@ -5813,9 +5734,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_cannotOverrideNonOverridableInheritedSRWithDifferentNameCasing()
       throws Exception {
     // Define submit requirement in root project and disallow override.
@@ -5863,6 +5781,235 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
+  public void globalSubmitRequirement_storedForClosedChanges() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("global-submit-requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.UNSATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      gApi.changes().id(changeId).current().submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().expression().expressionString())
+          .isEqualTo("topic:test");
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideNotAllowed_globalEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=+2"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote does not satisfy submit requirement, because the global definition is evaluated.
+      voteLabel(changeId, "CoDe-reView", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+      // In addition, the legacy submit requirement is emitted, since the status mismatch
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      // Setting the topic satisfies the global definition.
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideAllowed_projectRequirementEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=+2"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Setting the topic does not satisfy submit requirement, because the project definition is
+      // evaluated.
+      gApi.changes().id(changeId).topic("test");
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // There is no mismatch with legacy submit requirement, so the single result is emitted.
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Voting satisfies the project definition.
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusMatches_globalReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(/*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated, but only the global is returned, since both are satisfied
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusDoesNotMatch_bothRecordsReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated and both are returned, since result mismatch
+      voteLabel(changeId, "Code-Review", 2);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(changeId).topic("test");
+      gApi.changes().id(changeId).reviewer(admin.id().toString()).deleteVote(LabelId.CODE_REVIEW);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -6446,7 +6593,7 @@
             requirementName,
             status,
             results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
                 .collect(toImmutableList())));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 96db71a..6a52eef 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -206,19 +206,19 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
-      })
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)
   public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
+    rule.numberOfEvaluations.set(0);
     PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+
     String changeId = r.getChangeId();
 
-    rule.numberOfEvaluations.set(0);
     gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
 
-    // Submit rules are invoked
+    // Submit rules are invoked when the change was uploaded, further calls loaded submit records
+    // from the change index.
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 07a5a9c..ff8a2d0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -34,7 +34,10 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -44,12 +47,15 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -58,13 +64,18 @@
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
@@ -75,6 +86,7 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
   @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject
   @Named("change_kind")
@@ -260,6 +272,113 @@
   }
 
   @Test
+  public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setFunction(LabelFunction.NO_BLOCK));
+      u.save();
+    }
+
+    // This test is covering the backfilling logic for changes which have been submitted, based on
+    // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
+    // verifies that for such changes copied approvals are returned from the API even if the copied
+    // votes were not persisted as Copied-Label footers.
+    //
+    // In other words, this test verifies that given a change that was approved by a copied vote and
+    // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
+    // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
+    // got written to NoteDb on submit.
+    //
+    // Creating such a change would be possible by running the old Gerrit code from before Gerrit
+    // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
+    // longer available, the test needs to apply a trick to create a change in this state. It
+    // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
+    // old patch set is still present on the current patch set and allows to submit the change.
+    // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
+    // submit rule results in a Submitted-With footer that records the label as approved (although
+    // the label is actually not present on the current patch set). This is exactly the change state
+    // that we would have had by running the old code if submit was based on a copied label. As
+    // result of the backfilling logic we expect that this "copied" label (the label that is
+    // mentioned in the Submitted-With footer) is returned from the API.
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
+      // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote on patch-set 1
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      // Upload patch-set 2. Change user's "Verified" vote on PS2.
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("new_file")
+          .content("content")
+          .commitMessage("Upload PS2")
+          .create();
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, 1);
+
+      // Upload patch-set 3
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("another_file")
+          .content("content")
+          .commitMessage("Upload PS3")
+          .create();
+      vote(admin, changeId, 2, 1);
+
+      List<PatchSetApproval> patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // There's no verified approval on PS#3.
+      assertThat(
+              patchSetApprovals.stream()
+                  .filter(
+                      a ->
+                          a.accountId().equals(user.id())
+                              && a.label().equals(TestLabels.verified().getName())
+                              && a.patchSetId().get() == 3)
+                  .collect(Collectors.toList()))
+          .isEmpty();
+
+      // Submit the change. The TestSubmitRule will store a "submit record" containing a label
+      // voted by user, but the latest patch-set does not have an approval for this user, hence
+      // it will be copied if we request approvals after the change is merged.
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.changes().id(changeId).current().submit();
+
+      patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // Get the copied approval for user on PS3 for the "Verified" label.
+      PatchSetApproval verifiedApproval =
+          patchSetApprovals.stream()
+              .filter(
+                  a ->
+                      a.accountId().equals(user.id())
+                          && a.label().equals(TestLabels.verified().getName())
+                          && a.patchSetId().get() == 3)
+              .collect(MoreCollectors.onlyElement());
+
+      assertCopied(
+          verifiedApproval,
+          /* psId= */ 3,
+          TestLabels.verified().getName(),
+          (short) 1,
+          /* copied= */ true);
+    }
+  }
+
+  @Test
   public void stickyOnCopyValues() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
@@ -1381,4 +1500,29 @@
     assertThat(approval.value()).isEqualTo(value);
     assertThat(approval.copied()).isEqualTo(copied);
   }
+
+  /**
+   * Test submit rule that always return a passing record with a "Verified" label applied by {@link
+   * TestSubmitRule#userAccountId}.
+   */
+  private static class TestSubmitRule implements SubmitRule {
+    Account.Id userAccountId;
+
+    TestSubmitRule(Account.Id userAccountId) {
+      this.userAccountId = userAccountId;
+    }
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "Verified";
+      label.status = SubmitRecord.Label.Status.OK;
+      label.appliedBy = userAccountId;
+      record.labels = Arrays.asList(label);
+      return Optional.of(record);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 8367f60..14a8e98 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,7 +34,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayDeque;
-import java.util.Map;
 import org.apache.commons.lang.RandomStringUtils;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -144,7 +142,6 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -156,25 +153,6 @@
             .getObjectId();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
-
-    // As the submodules have changed commits, the superproject tree will be
-    // different, so we cannot directly compare the trees here, so make
-    // assumptions only about the changed branches:
-    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // each change is updated and the respective target branch is updated:
-      assertThat(preview).hasSize(5);
-    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
-      // Either the first is used first as is, then the second and third need
-      // rebasing, or those two stay as is and the first is rebased.
-      // add in 2 master branches, expect 3 or 4:
-      assertThat(preview.size()).isAnyOf(3, 4);
-    } else {
-      assertThat(preview).hasSize(2);
-    }
   }
 
   @Test
@@ -661,12 +639,6 @@
   }
 
   @Test
-  public void branchCircularSubscriptionPreview() throws Exception {
-    testBranchCircularSubscription(
-        changeId -> gApi.changes().id(changeId).current().submitPreview());
-  }
-
-  @Test
   public void projectCircularSubscriptionWholeTopic() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 79484ca..f3b13d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -145,7 +145,6 @@
           RestCall.get("/changes/%s/revisions/%s/related"),
           RestCall.get("/changes/%s/revisions/%s/review"),
           RestCall.post("/changes/%s/revisions/%s/review"),
-          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
           RestCall.post("/changes/%s/revisions/%s/submit"),
           RestCall.get("/changes/%s/revisions/%s/submit_type"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index d967f48..317053e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -81,7 +81,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -104,10 +103,8 @@
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
@@ -148,162 +145,25 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
   public void submitSingleChange() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
 
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // The change is updated as well:
-      assertThat(actual).hasSize(2);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
     submit(change.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-
-    try (BinaryResult request =
-        gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
-      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
-      submit(change4.getChangeId());
-    } catch (RestApiException e) {
-      switch (getSubmitType()) {
-        case FAST_FORWARD_ONLY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.");
-          break;
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().commitId().name();
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Cannot rebase "
-                      + change2hash
-                      + ": The change could "
-                      + "not be rebased due to a conflict during merge.\n\n"
-                      + "merge conflict(s):\n"
-                      + "a.txt");
-          break;
-        case MERGE_ALWAYS:
-        case MERGE_IF_NECESSARY:
-        case INHERIT:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.");
-          break;
-        case CHERRY_PICK:
-        default:
-          assertWithMessage("Should not reach here.").fail();
-          break;
-      }
-
-      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
-      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-    }
-  }
-
-  @Test
-  public void submitMultipleChangesPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
-    Map<String, Map<String, Integer>> expected = new HashMap<>();
-    expected.put(project.get(), new HashMap<>());
-    expected.get(project.get()).put("refs/heads/master", 3);
-
-    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      // CherryPick ignores dependencies, thus only change and destination
-      // branch refs are modified.
-      assertThat(actual).hasSize(2);
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
-      // destination branch will be modified.
-      assertThat(actual).hasSize(4);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    // now check we actually have the same content:
-    approve(change2.getChangeId());
-    submit(change4.getChangeId());
-    assertTrees(project, actual);
+    headAfterSubmit = projectOperations.project(project).getHead("master");
+    assertThat(headAfterSubmit).isNotEqualTo(initialHead);
   }
 
   /**
@@ -1238,14 +1098,11 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
@@ -1259,14 +1116,11 @@
     change.assertOkStatus();
     assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 157c93c..c4f8f2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -38,22 +38,10 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
@@ -184,8 +172,6 @@
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
 
-    // get a preview before submitting:
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -197,23 +183,9 @@
     if (isSubmitWholeTopicEnabled()) {
       assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
-      // check that the preview matched what happened:
-      assertThat(preview).hasSize(3);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
-      assertTrees(p1, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
-      assertTrees(p2, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
-      assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
-      assertThat(preview).hasSize(1);
-      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -281,13 +253,6 @@
               + "merged due to a path conflict. Please rebase the change locally "
               + "and upload the rebased commit for review.";
 
-      // Get a preview before submitting:
-      RestApiException thrown =
-          assertThrows(
-              RestApiException.class,
-              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
-      assertThat(thrown.getMessage()).isEqualTo(msg);
-
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -756,34 +721,4 @@
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
-
-  @Test
-  public void testPreviewSubmitTgz() throws Throwable {
-    Project.NameKey p1 = projectOperations.newProject().create();
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
-    approve(change1.getChangeId());
-
-    // get a preview before submitting:
-    File tempfile;
-    try (BinaryResult request =
-        gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
-      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
-      tempfile = File.createTempFile("test", null);
-      request.writeTo(Files.newOutputStream(tempfile.toPath()));
-    }
-
-    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
-    List<String> untarredFiles = new ArrayList<>();
-    try (TarArchiveInputStream tarInputStream =
-        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry;
-      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
-        untarredFiles.add(entry.getName());
-      }
-    }
-    assertThat(untarredFiles).containsExactly(p1.get() + ".git");
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ce92536..fdb2ed7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -192,6 +194,26 @@
     assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
   }
 
+  @Test
+  public void deleteRefsForBranch() throws Exception {
+    BranchNameKey refsForBranch = BranchNameKey.create(project, "refs/for/master");
+
+    // Creating a branch under refs/for/ is not allowed through the API, hence create it directly in
+    // the remote repo.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      repo.branch(refsForBranch.branch()).commit().message("Initial empty commit").create();
+    }
+
+    assertThat(branch(refsForBranch).get().canDelete).isTrue();
+    String branchRev = branch(refsForBranch).get().revision;
+
+    branch(refsForBranch).delete();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), refsForBranch.branch(), branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(refsForBranch).get());
+  }
+
   private void blockForcePush() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a97fb49..7e0bce9 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -130,6 +130,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -152,6 +154,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -180,6 +184,9 @@
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
       assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
     }
   }
 
@@ -227,6 +234,13 @@
       assertSubmittedTogether(id4, id4, id3, id2);
       assertSubmittedTogether(id5);
       assertSubmittedTogether(id6, id6, id5);
+
+      assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+      assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id5);
+      assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index a23fb7b..fb51ae0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -21,6 +21,8 @@
 
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -40,6 +42,7 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Map;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
@@ -49,6 +52,7 @@
   @Inject SubmitRequirementsEvaluator evaluator;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private ChangeData changeData;
   private String changeId;
@@ -110,6 +114,93 @@
   }
 
   @Test
+  public void globalSubmitRequirementEvaluated() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "global-config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "project-config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(2);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideAllowed_projectResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            true);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideNotAllowedAllowed_globalResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
       throws Exception {
     SubmitRequirement sr =
@@ -292,13 +383,27 @@
       @Nullable String applicabilityExpr,
       String submittabilityExpr,
       @Nullable String overrideExpr) {
+    return createSubmitRequirement(
+        /*name= */ "sr-name",
+        applicabilityExpr,
+        submittabilityExpr,
+        overrideExpr,
+        /*allowOverrideInChildProjects=*/ false);
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      String name,
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr,
+      boolean allowOverrideInChildProjects) {
     return SubmitRequirement.builder()
-        .setName("sr-name")
+        .setName(name)
         .setDescription(Optional.of("sr-description"))
         .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
         .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
         .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
-        .setAllowOverrideInChildProjects(false)
+        .setAllowOverrideInChildProjects(allowOverrideInChildProjects)
         .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 05eb6e0..c6b8a33 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -180,6 +180,43 @@
   }
 
   @Test
+  public void defaultSubmitRule_withNonExistingLabel() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.OK,
+            Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).isEmpty();
+  }
+
+  @Test
+  public void defaultSubmitRule_withExistingAndNonExistingLabels() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.OK,
+            Arrays.asList(
+                createLabel("Non-Existing", Label.Status.OK),
+                createLabel("Code-Review", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    // The "Non-Existing" label was skipped since it does not exist in the project config.
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
   public void customSubmitRule_noLabels_withStatusOk() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
diff --git a/plugins/replication b/plugins/replication
index af3dee4..d9c59a0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit af3dee4dc18c47a60ab7a97333e87c98b8379173
+Subproject commit d9c59a0be1c423706b2c4c74c955ebbeca5a894d
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index f5bbf00..6673cdf 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -187,11 +187,11 @@
 export class MyCustomElement extends ...{
     constructor() {
         super(); //This is mandatory to call parent constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
-        return this._userService.activeUserName();
+        return this._userModel.activeUserName();
     }
 }
 ```
@@ -203,12 +203,12 @@
 export class MyCustomElement extends ...{
     created() {
         // Incorrect: assign all dependencies in the constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
         // Incorrect: use appContext outside of a constructor
-        return appContext.userService.activeUserName();
+        return appContext.userModel.activeUserName();
     }
 }
 ```
@@ -237,7 +237,7 @@
     constructor() {
         super();
         // Assign services here
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
         // Code from the created method - put it before existing actions in constructor
         createdAction1();
         createdAction2();
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0029f5c..def693d 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -100,4 +100,6 @@
   ATTENTION_SET_CHIP = 'attention-set-chip',
   SAVE_COMMENT = 'save-comment',
   COMMENT_SAVED = 'comment-saved',
+  DISCARD_COMMENT = 'discard-comment',
+  COMMENT_DISCARDED = 'comment-discarded',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 037e11f..88ade26 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -29,7 +29,6 @@
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {serverConfig$} from '../../../services/config/config-model';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -77,6 +76,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly configModel = getAppContext().configModel;
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoBranchesSuggestions(input);
@@ -86,7 +87,7 @@
     super.connectedCallback();
     if (!this.repoName) return;
 
-    subscribe(this, serverConfig$, config => {
+    subscribe(this, this.configModel.serverConfig$, config => {
       this.privateChangesEnabled =
         config?.change?.disable_private_changes ?? false;
     });
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 7e04281..cf5d952 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
@@ -115,7 +115,8 @@
   @property({type: Boolean})
   _loading = true;
 
-  private originalInheritsFrom?: ProjectInfo;
+  // private but used in the tests
+  originalInheritsFrom?: ProjectInfo;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -131,7 +132,7 @@
     this._modified = true;
   }
 
-  _repoChanged(repo: RepoName) {
+  _repoChanged(repo?: RepoName) {
     this._loading = true;
 
     if (!repo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
rename to polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 1ccfd5e..a5159ae 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -15,23 +15,42 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-access.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+import '../../../test/common-test-setup-karma';
+import './gr-repo-access';
+import {GrRepoAccess} from './gr-repo-access';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
   addListenerForTest,
   mockPromise,
+  queryAll,
+  queryAndAssert,
   stubRestApi,
-} from '../../../test/test-utils.js';
+} from '../../../test/test-utils';
+import {
+  ChangeInfo,
+  GitRef,
+  RepoName,
+  UrlEncodedRepoName,
+} from '../../../types/common';
+import {PermissionAction} from '../../../constants/constants';
+import {PageErrorEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  AutocompleteCommitEvent,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {GrPermission} from '../gr-permission/gr-permission';
+import {createChange} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-repo-access');
 
 suite('gr-repo-access tests', () => {
-  let element;
+  let element: GrRepoAccess;
 
-  let repoStub;
+  let repoStub: sinon.SinonStub;
 
   const accessRes = {
     local: {
@@ -39,13 +58,13 @@
         permissions: {
           owner: {
             rules: {
-              234: {action: 'ALLOW'},
-              123: {action: 'DENY'},
+              234: {action: PermissionAction.ALLOW},
+              123: {action: PermissionAction.DENY},
             },
           },
           read: {
             rules: {
-              234: {action: 'ALLOW'},
+              234: {action: PermissionAction.ALLOW},
             },
           },
         },
@@ -59,11 +78,13 @@
         name: 'Maintainers',
       },
     },
-    config_web_links: [{
-      name: 'gitiles',
-      target: '_blank',
-      url: 'https://my/site/+log/123/project.config',
-    }],
+    config_web_links: [
+      {
+        name: 'gitiles',
+        target: '_blank',
+        url: 'https://my/site/+log/123/project.config',
+      },
+    ],
     can_upload: true,
   };
   const accessRes2 = {
@@ -73,7 +94,7 @@
           accessDatabase: {
             rules: {
               group1: {
-                action: 'ALLOW',
+                action: PermissionAction.ALLOW,
               },
             },
           },
@@ -82,15 +103,17 @@
     },
   };
   const repoRes = {
+    id: '' as UrlEncodedRepoName,
     labels: {
       'Code-Review': {
         values: {
-          ' 0': 'No score',
+          '0': 'No score',
           '-1': 'I would prefer this is not merged as is',
           '-2': 'This shall not be merged',
           '+1': 'Looks good to me, but someone else must approve',
           '+2': 'Looks good to me, approved',
         },
+        default_value: 0,
       },
     },
   };
@@ -106,7 +129,7 @@
   };
   setup(async () => {
     element = basicFixture.instantiate();
-    stubRestApi('getAccount').returns(Promise.resolve(null));
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
     repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
     element._loading = false;
     element._ownerOf = [];
@@ -115,43 +138,51 @@
   });
 
   test('_repoChanged called when repo name changes', async () => {
-    sinon.stub(element, '_repoChanged');
-    element.repo = 'New Repo';
+    const repoChangedStub = sinon.stub(element, '_repoChanged');
+    element.repo = 'New Repo' as RepoName;
     await flush();
-    assert.isTrue(element._repoChanged.called);
+    assert.isTrue(repoChangedStub.called);
   });
 
   test('_repoChanged', async () => {
-    const accessStub = stubRestApi(
-        'getRepoAccessRights');
+    const accessStub = stubRestApi('getRepoAccessRights');
 
-    accessStub.withArgs('New Repo').returns(
-        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-    accessStub.withArgs('Another New Repo')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = stubRestApi(
-        'getCapabilities');
+    accessStub
+      .withArgs('New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub
+      .withArgs('Another New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = stubRestApi('getCapabilities');
     capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
 
-    await element._repoChanged('New Repo');
+    await element._repoChanged('New Repo' as RepoName);
     assert.isTrue(accessStub.called);
     assert.isTrue(capabilitiesStub.called);
     assert.isTrue(repoStub.called);
     assert.isNotOk(element._inheritsFrom);
     assert.deepEqual(element._local, accessRes.local);
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes.local));
+    assert.deepEqual(
+      element._sections,
+      toSortedPermissionsArray(accessRes.local)
+    );
     assert.deepEqual(element._labels, repoRes.labels);
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'block');
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'block'
+    );
 
-    await element._repoChanged('Another New Repo');
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes2.local));
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'none');
+    await element._repoChanged('Another New Repo' as RepoName);
+    assert.deepEqual(
+      element._sections,
+      toSortedPermissionsArray(accessRes2.local)
+    );
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'none'
+    );
   });
 
   test('_repoChanged when repo changes to undefined returns', async () => {
@@ -161,10 +192,12 @@
         name: 'Access Database',
       },
     };
-    const accessStub = stubRestApi('getRepoAccessRights')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = stubRestApi(
-        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+    const accessStub = stubRestApi('getRepoAccessRights').returns(
+      Promise.resolve(JSON.parse(JSON.stringify(accessRes2)))
+    );
+    const capabilitiesStub = stubRestApi('getCapabilities').returns(
+      Promise.resolve(capabilitiesRes)
+    );
 
     await element._repoChanged();
     assert.isFalse(accessStub.called);
@@ -173,34 +206,39 @@
   });
 
   test('_computeParentHref', () => {
-    const repoName = 'test-repo';
-    assert.equal(element._computeParentHref(repoName),
-        '/admin/repos/test-repo,access');
+    assert.equal(
+      element._computeParentHref('test-repo' as RepoName),
+      '/admin/repos/test-repo,access'
+    );
   });
 
   test('_computeMainClass', () => {
-    let ownerOf = ['refs/*'];
+    let ownerOf = ['refs/*'] as GitRef[];
     const editing = true;
     const canUpload = false;
-    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'admin editing');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, false), 'admin');
+    assert.equal(
+      element._computeMainClass(ownerOf, canUpload, editing),
+      'admin editing'
+    );
     ownerOf = [];
-    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'editing');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, false), '');
+    assert.equal(
+      element._computeMainClass(ownerOf, canUpload, editing),
+      'editing'
+    );
   });
 
   test('inherit section', async () => {
     element._local = {};
     element._ownerOf = [];
-    sinon.stub(element, '_computeParentHref');
+    const computeParentHrefStub = sinon.stub(element, '_computeParentHref');
     await flush();
 
     // Nothing should appear when no inherit from and not in edit mode.
     assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
     // The autocomplete should be hidden, and the link should be  displayed.
-    assert.isFalse(element._computeParentHref.called);
+    assert.isFalse(computeParentHrefStub.called);
     // When in edit mode, the autocomplete should appear.
     element._editing = true;
     // When editing, the autocomplete should still not be shown.
@@ -208,33 +246,45 @@
 
     element._editing = false;
     element._inheritsFrom = {
-      id: '1234',
-      name: 'another-repo',
+      id: '1234' as UrlEncodedRepoName,
+      name: 'another-repo' as RepoName,
     };
     await flush();
 
     // When there is a parent project, the link should be displayed.
     assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-        'none');
-    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-    assert.isTrue(element._computeParentHref.called);
+    assert.notEqual(
+      getComputedStyle(element.$.inheritFromName).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+      ).display,
+      'none'
+    );
+    assert.isTrue(computeParentHrefStub.called);
     element._editing = true;
     // When editing, the autocomplete should be shown.
     assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
     assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+      ).display,
+      'none'
+    );
   });
 
   test('_handleUpdateInheritFrom', async () => {
-    element._inheritFromFilter = 'foo bar baz';
-    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+    element._inheritFromFilter = 'foo bar baz' as RepoName;
+    element._handleUpdateInheritFrom({
+      detail: {value: 'abc+123'},
+    } as CustomEvent);
     await flush();
     assert.isOk(element._inheritsFrom);
-    assert.equal(element._inheritsFrom.id, 'abc+123');
-    assert.equal(element._inheritsFrom.name, 'foo bar baz');
+    assert.equal(element._inheritsFrom!.id, 'abc+123');
+    assert.equal(element._inheritsFrom!.name, 'foo bar baz' as RepoName);
   });
 
   test('_computeLoadingClass', () => {
@@ -243,84 +293,113 @@
   });
 
   test('fires page-error', async () => {
-    const response = {status: 404};
+    const response = {status: 404} as Response;
 
-    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
-      errFn(response);
+    stubRestApi('getRepoAccessRights').callsFake((_repoName, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
       return Promise.resolve(undefined);
     });
 
     const promise = mockPromise();
     addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
       promise.resolve();
     });
 
-    element.repo = 'test';
+    element.repo = 'test' as RepoName;
     await promise;
   });
 
   suite('with defined sections', () => {
     const testEditSaveCancelBtns = async (
-        shouldShowSave,
-        shouldShowSaveReview
+      shouldShowSave: boolean,
+      shouldShowSaveReview: boolean
     ) => {
       // Edit button is visible and Save button is hidden.
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      assert.equal(element.$.editBtn.innerText, 'EDIT');
       assert.equal(
-          getComputedStyle(element.$.editInheritFromInput).display,
-          'none'
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn')).display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+        'none'
+      );
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'EDIT'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
       );
       element._inheritsFrom = {
-        id: 'test-project',
+        id: 'test-project' as UrlEncodedRepoName,
       };
       await flush();
       assert.equal(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
       );
 
-      MockInteractions.tap(element.$.editBtn);
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#editBtn'));
       await flush();
 
       // Edit button changes to Cancel button, and Save button is visible but
       // disabled.
-      assert.equal(element.$.editBtn.innerText, 'CANCEL');
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'CANCEL'
+      );
       if (shouldShowSaveReview) {
         assert.notEqual(
-            getComputedStyle(element.$.saveReviewBtn).display,
-            'none'
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+            .display,
+          'none'
         );
-        assert.isTrue(element.$.saveReviewBtn.disabled);
+        assert.isTrue(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
       }
       if (shouldShowSave) {
-        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.isTrue(element.$.saveBtn.disabled);
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn'))
+            .display,
+          'none'
+        );
+        assert.isTrue(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
       }
       assert.notEqual(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
       );
 
       // Save button should be enabled after access is modified
       element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true,
-            bubbles: true,
-          })
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
       );
       if (shouldShowSaveReview) {
-        assert.isFalse(element.$.saveReviewBtn.disabled);
+        assert.isFalse(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
       }
       if (shouldShowSave) {
-        assert.isFalse(element.$.saveBtn.disabled);
+        assert.isFalse(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
       }
     };
 
@@ -337,16 +416,20 @@
     });
 
     test('removing an added section', async () => {
-      element.editing = true;
+      element._editing = true;
       await flush();
-      assert.equal(element._sections.length, 1);
-      element.shadowRoot
-          .querySelector('gr-access-section').dispatchEvent(
-              new CustomEvent('added-section-removed', {
-                composed: true, bubbles: true,
-              }));
+      assert.equal(element._sections!.length, 1);
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      ).dispatchEvent(
+        new CustomEvent('added-section-removed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
       await flush();
-      assert.equal(element._sections.length, 0);
+      assert.equal(element._sections!.length, 0);
     });
 
     test('button visibility for non ref owner', () => {
@@ -354,64 +437,77 @@
       assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
     });
 
-    test('button visibility for non ref owner with upload privilege',
-        async () => {
-          element._canUpload = true;
-          await flush();
-          testEditSaveCancelBtns(false, true);
-        });
+    test('button visibility for non ref owner with upload privilege', async () => {
+      element._canUpload = true;
+      await flush();
+      testEditSaveCancelBtns(false, true);
+    });
 
     test('button visibility for ref owner', async () => {
-      element._ownerOf = ['refs/for/*'];
+      element._ownerOf = ['refs/for/*'] as GitRef[];
       await flush();
       testEditSaveCancelBtns(true, false);
     });
 
     test('button visibility for ref owner and upload', async () => {
-      element._ownerOf = ['refs/for/*'];
+      element._ownerOf = ['refs/for/*'] as GitRef[];
       element._canUpload = true;
       await flush();
       testEditSaveCancelBtns(true, false);
     });
 
     test('_handleAccessModified called with event fired', async () => {
-      sinon.spy(element, '_handleAccessModified');
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
       element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
       await flush();
-      assert.isTrue(element._handleAccessModified.called);
+      assert.isTrue(handleAccessModifiedSpy.called);
     });
 
     test('_handleAccessModified called when parent changes', async () => {
       element._inheritsFrom = {
-        id: 'test-project',
+        id: 'test-project' as UrlEncodedRepoName,
       };
       await flush();
-      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
-          new CustomEvent('commit', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      sinon.spy(element, '_handleAccessModified');
+      queryAndAssert<GrAutocomplete>(
+        element,
+        '#editInheritFromInput'
+      ).dispatchEvent(
+        new CustomEvent('commit', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
       element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
+        new CustomEvent('access-modified', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
       await flush();
-      assert.isTrue(element._handleAccessModified.called);
+      assert.isTrue(handleAccessModifiedSpy.called);
     });
 
     test('_handleSaveForReview', async () => {
-      const saveStub =
-          stubRestApi('setRepoAccessRightsForReview');
+      const saveStub = stubRestApi('setRepoAccessRightsForReview');
       sinon.stub(element, '_computeAddAndRemove').returns({
         add: {},
         remove: {},
       });
-      element._handleSaveForReview();
+      element._handleSaveForReview(new Event('test'));
       await flush();
       assert.isFalse(saveStub.called);
     });
@@ -522,29 +618,35 @@
 
     test('_handleSaveForReview parent change', async () => {
       element._inheritsFrom = {
-        id: 'test-project',
+        id: 'test-project' as UrlEncodedRepoName,
       };
-      element._originalInheritsFrom = {
-        id: 'test-project-original',
+      element.originalInheritsFrom = {
+        id: 'test-project-original' as UrlEncodedRepoName,
       };
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'test-project', add: {}, remove: {},
+        parent: 'test-project',
+        add: {},
+        remove: {},
       });
     });
 
     test('_handleSaveForReview new parent with spaces', async () => {
-      element._inheritsFrom = {id: 'spaces+in+project+name'};
-      element._originalInheritsFrom = {id: 'old-project'};
+      element._inheritsFrom = {
+        id: 'spaces+in+project+name' as UrlEncodedRepoName,
+      };
+      element.originalInheritsFrom = {id: 'old-project' as UrlEncodedRepoName};
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'spaces in project name', add: {}, remove: {},
+        parent: 'spaces in project name',
+        add: {},
+        remove: {},
       });
     });
 
     test('_handleSaveForReview rules', async () => {
       // Delete a rule.
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
       await flush();
       let expectedInput = {
         add: {},
@@ -563,10 +665,10 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Undo deleting a rule.
-      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+      delete element._local!['refs/*'].permissions.owner.rules[123].deleted;
 
       // Modify a rule.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      element._local!['refs/*'].permissions.owner.rules[123].modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -597,7 +699,9 @@
 
     test('_computeAddAndRemove permissions', async () => {
       // Add a new rule to a permission.
-      let expectedInput = {
+      let expectedInput = {};
+
+      expectedInput = {
         add: {
           'refs/*': {
             permissions: {
@@ -614,22 +718,27 @@
         },
         remove: {},
       };
-
-      element.shadowRoot
-          .querySelector('gr-access-section').shadowRoot
-          .querySelector('gr-permission')
-          ._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      queryAndAssert<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
 
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Remove the added rule.
-      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
+      delete element._local!['refs/*'].permissions.owner.rules.Maintainers;
 
       // Delete a permission.
-      element._local['refs/*'].permissions.owner.deleted = true;
+      element._local!['refs/*'].permissions.owner.deleted = true;
       await flush();
+
       expectedInput = {
         add: {},
         remove: {
@@ -643,10 +752,10 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Undo delete permission.
-      delete element._local['refs/*'].permissions.owner.deleted;
+      delete element._local!['refs/*'].permissions.owner.deleted;
 
       // Modify a permission.
-      element._local['refs/*'].permissions.owner.modified = true;
+      element._local!['refs/*'].permissions.owner.modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -675,7 +784,9 @@
 
     test('_computeAddAndRemove sections', async () => {
       // Add a new permission to a section
-      let expectedInput = {
+      let expectedInput = {};
+
+      expectedInput = {
         add: {
           'refs/*': {
             permissions: {
@@ -689,8 +800,10 @@
         },
         remove: {},
       };
-      element.shadowRoot
-          .querySelector('gr-access-section')._handleAddPermission();
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )._handleAddPermission();
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
@@ -716,18 +829,23 @@
         },
         remove: {},
       };
-      const newPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[2];
-      newPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      const newPermission = queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[2];
+      newPermission._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Modify a section reference.
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
+      element._local!['refs/*'].updatedId = 'refs/for/bar';
+      element._local!['refs/*'].modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -735,13 +853,13 @@
             modified: true,
             updatedId: 'refs/for/bar',
             permissions: {
-              'owner': {
+              owner: {
                 rules: {
                   234: {action: 'ALLOW'},
                   123: {action: 'DENY'},
                 },
               },
-              'read': {
+              read: {
                 rules: {
                   234: {action: 'ALLOW'},
                 },
@@ -771,7 +889,7 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Delete a section.
-      element._local['refs/*'].deleted = true;
+      element._local!['refs/*'].deleted = true;
       await flush();
       expectedInput = {
         add: {},
@@ -786,7 +904,9 @@
 
     test('_computeAddAndRemove new section', async () => {
       // Add a new permission to a section
-      let expectedInput = {
+      let expectedInput = {};
+
+      expectedInput = {
         add: {
           'refs/for/*': {
             added: true,
@@ -814,8 +934,10 @@
         },
         remove: {},
       };
-      const newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
+      const newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
       newSection._handleAddPermission();
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
@@ -844,14 +966,17 @@
         remove: {},
       };
 
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
+      queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      )._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
+      element._local!['refs/for/*'].updatedId = 'refs/for/new';
       await flush();
       expectedInput = {
         add: {
@@ -881,10 +1006,12 @@
 
     test('_computeAddAndRemove combinations', async () => {
       // Modify rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      element._local['refs/*'].permissions.owner.deleted = true;
+      element._local!['refs/*'].permissions.owner.rules[123].modified = true;
+      element._local!['refs/*'].permissions.owner.deleted = true;
       await flush();
-      let expectedInput = {
+      let expectedInput = {};
+
+      expectedInput = {
         add: {},
         remove: {
           'refs/*': {
@@ -896,13 +1023,13 @@
       };
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
       // Delete rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = false;
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      element._local!['refs/*'].permissions.owner.rules[123].modified = false;
+      element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Also modify a different rule inside of another permission.
-      element._local['refs/*'].permissions.read.modified = true;
+      element._local!['refs/*'].permissions.read.modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -929,10 +1056,10 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
       // Modify both permissions with an exclusive bit. Owner is still
       // deleted.
-      element._local['refs/*'].permissions.owner.exclusive = true;
-      element._local['refs/*'].permissions.owner.modified = true;
-      element._local['refs/*'].permissions.read.exclusive = true;
-      element._local['refs/*'].permissions.read.modified = true;
+      element._local!['refs/*'].permissions.owner.exclusive = true;
+      element._local!['refs/*'].permissions.owner.modified = true;
+      element._local!['refs/*'].permissions.read.exclusive = true;
+      element._local!['refs/*'].permissions.read.modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -960,12 +1087,17 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Add a rule to the existing permission;
-      const readPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[1];
-      readPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      const readPermission = queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[1];
+      readPermission._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
       await flush();
 
       expectedInput = {
@@ -995,8 +1127,8 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Change one of the refs
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
+      element._local!['refs/*'].updatedId = 'refs/for/bar';
+      element._local!['refs/*'].modified = true;
       await flush();
 
       expectedInput = {
@@ -1032,21 +1164,26 @@
           },
         },
       };
-      element._local['refs/*'].deleted = true;
+      element._local!['refs/*'].deleted = true;
       await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Add a new section.
       MockInteractions.tap(element.$.addReferenceBtn);
-      let newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
+      let newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
       newSection._handleAddPermission();
       await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
+      queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      )._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
       // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
+      element._local!['refs/for/*'].updatedId = 'refs/for/new';
       await flush();
 
       expectedInput = {
@@ -1079,8 +1216,9 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Modify newly added rule inside new ref.
-      element._local['refs/for/*'].permissions['label-Code-Review'].
-          rules['Maintainers'].modified = true;
+      element._local!['refs/for/*'].permissions['label-Code-Review'].rules[
+        'Maintainers'
+      ].modified = true;
       await flush();
       expectedInput = {
         add: {
@@ -1115,15 +1253,17 @@
       // Add a second new section.
       MockInteractions.tap(element.$.addReferenceBtn);
       await flush();
-      newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[2];
+      newSection = queryAll<GrAccessSection>(element, 'gr-access-section')[2];
       newSection._handleAddPermission();
       await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
+      queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      )._handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
       // Modify a the reference from the default value.
-      element._local['refs/for/**'].updatedId = 'refs/for/new2';
+      element._local!['refs/for/**'].updatedId = 'refs/for/new2';
       await flush();
       expectedInput = {
         add: {
@@ -1178,16 +1318,16 @@
       // Unsaved changes are discarded when editing is cancelled.
       MockInteractions.tap(element.$.editBtn);
       await flush();
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
+      assert.equal(element._sections!.length, 1);
+      assert.equal(Object.keys(element._local!).length, 1);
       MockInteractions.tap(element.$.addReferenceBtn);
       await flush();
-      assert.equal(element._sections.length, 2);
-      assert.equal(Object.keys(element._local).length, 2);
+      assert.equal(element._sections!.length, 2);
+      assert.equal(Object.keys(element._local!).length, 2);
       MockInteractions.tap(element.$.editBtn);
       await flush();
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
+      assert.equal(element._sections!.length, 1);
+      assert.equal(Object.keys(element._local!).length, 1);
     });
 
     test('_handleSave', async () => {
@@ -1216,24 +1356,25 @@
         },
       };
       stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveStub = stubRestApi(
-          'setRepoAccessRights')
-          .returns(new Promise(r => resolver = r));
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      let resolver: (value: Response | PromiseLike<Response>) => void;
+      const saveStub = stubRestApi('setRepoAccessRights').returns(
+        new Promise(r => (resolver = r))
+      );
 
-      element.repo = 'test-repo';
+      element.repo = 'test-repo' as RepoName;
       sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
 
       element._modified = true;
       MockInteractions.tap(element.$.saveBtn);
       await flush();
       assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
+      resolver!({status: 200} as Response);
       await flush();
       assert.isTrue(saveStub.called);
-      assert.isTrue(GerritNav.navigateToChange.notCalled);
+      assert.isTrue(navigateToChangeStub.notCalled);
     });
 
     test('_handleSaveForReview', async () => {
@@ -1262,26 +1403,27 @@
         },
       };
       stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
       const saveForReviewStub = stubRestApi(
-          'setRepoAccessRightsForReview')
-          .returns(new Promise(r => resolver = r));
+        'setRepoAccessRightsForReview'
+      ).returns(new Promise(r => (resolver = r)));
 
-      element.repo = 'test-repo';
+      element.repo = 'test-repo' as RepoName;
       sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
 
       element._modified = true;
       MockInteractions.tap(element.$.saveReviewBtn);
       await flush();
       assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
+      resolver!(createChange());
       await flush();
       assert.isTrue(saveForReviewStub.called);
-      assert.isTrue(GerritNav.navigateToChange
-          .lastCall.calledWithExactly({_number: 1}));
+      assert.isTrue(
+        navigateToChangeStub.lastCall.calledWithExactly(createChange())
+      );
     });
   });
 });
-
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 f309171..1076990 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -50,7 +50,6 @@
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
-import {preferences$} from '../../../services/user/user-model';
 import {subscribe} from '../../lit/subscription-controller';
 
 const STATES = {
@@ -122,11 +121,13 @@
 
   @state() private pluginConfigChanged = false;
 
+  private readonly userModel = getAppContext().userModel;
+
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    subscribe(this, preferences$, prefs => {
+    subscribe(this, this.userModel.preferences$, prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 172f807..f811ee2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -71,7 +71,7 @@
 ];
 
 interface Rule {
-  value: RuleValue;
+  value?: RuleValue;
 }
 
 interface RuleValue {
@@ -158,17 +158,17 @@
       // Observer _handleValueChange is called after the ready()
       // method finishes. Original values must be set later to
       // avoid set .modified flag to true
-      this._setOriginalRuleValues(this.rule.value);
+      this._setOriginalRuleValues(this.rule?.value);
     }
   }
 
-  _setupValues(rule: Rule) {
-    if (!rule.value) {
+  _setupValues(rule?: Rule) {
+    if (!rule?.value) {
       this._setDefaultRuleValues();
     }
   }
 
-  _computeForce(permission: AccessPermissionId, action: string) {
+  _computeForce(permission: AccessPermissionId, action?: string) {
     if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
       return true;
     }
@@ -176,7 +176,7 @@
     return AccessPermissionId.EDIT_TOPIC_NAME === permission;
   }
 
-  _computeForceClass(permission: AccessPermissionId, action: string) {
+  _computeForceClass(permission: AccessPermissionId, action?: string) {
     return this._computeForce(permission, action) ? 'force' : '';
   }
 
@@ -213,7 +213,7 @@
     return classList.join(' ');
   }
 
-  _computeForceOptions(permission: string, action: string) {
+  _computeForceOptions(permission: string, action?: string) {
     if (permission === AccessPermissionId.PUSH) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
@@ -259,7 +259,7 @@
   }
 
   _handleRemoveRule() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     if (this.rule.value.added) {
       fireEvent(this, 'added-rule-removed');
     }
@@ -269,13 +269,13 @@
   }
 
   _handleUndoRemove() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     this._deleted = false;
     delete this.rule.value.deleted;
   }
 
   _handleUndoChange() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) {
@@ -289,7 +289,7 @@
 
   @observe('rule.value.*')
   _handleValueChange() {
-    if (!this._originalRuleValues || !this.rule) {
+    if (!this._originalRuleValues || !this.rule?.value) {
       return;
     }
     this.rule.value.modified = true;
@@ -297,7 +297,8 @@
     fireEvent(this, 'access-modified');
   }
 
-  _setOriginalRuleValues(value: RuleValue) {
+  _setOriginalRuleValues(value?: RuleValue) {
+    if (value === undefined) return;
     this._originalRuleValues = {...value};
   }
 }
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
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 './gr-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions',
-        () => {
-          const ForcePushOptions = {
-            ALLOW: [
-              {name: 'Allow pushing (but not force pushing)', value: false},
-              {name: 'Allow pushing with or without force', value: true},
-            ],
-            BLOCK: [
-              {name: 'Block pushing with or without force', value: false},
-              {name: 'Block force pushing', value: true},
-            ],
-          };
-
-          const FORCE_EDIT_OPTIONS = [
-            {
-              name: 'No Force Edit',
-              value: false,
-            },
-            {
-              name: 'Force Edit',
-              value: true,
-            },
-          ];
-          let permission = 'push';
-          let action = 'ALLOW';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.ALLOW);
-
-          action = 'BLOCK';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.BLOCK);
-
-          action = 'DENY';
-          assert.isFalse(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action), '');
-          assert.equal(
-              element._computeForceOptions(permission, action).length, 0);
-
-          permission = 'editTopicName';
-          assert.isTrue(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), 'force');
-          assert.deepEqual(element._computeForceOptions(permission),
-              FORCE_EDIT_OPTIONS);
-          permission = 'submit';
-          assert.isFalse(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), '');
-          assert.deepEqual(element._computeForceOptions(permission), []);
-        });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority';
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
-      permission = 'label-Code-Review';
-      label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', max: 2, min: -2});
-      permission = 'push';
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
-      permission = 'submit';
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW'});
-    });
-
-    test('_setDefaultRuleValues', () => {
-      element.rule = {id: 123};
-      const defaultValue = {action: 'ALLOW'};
-      sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-      element._setDefaultRuleValues();
-      assert.isTrue(element._getDefaultRuleValues.called);
-      assert.equal(element.rule.value, defaultValue);
-    });
-
-    test('_computeOptions', () => {
-      const PRIORITY_OPTIONS = [
-        'BATCH',
-        'INTERACTIVE',
-      ];
-      const DROPDOWN_OPTIONS = [
-        'ALLOW',
-        'DENY',
-        'BLOCK',
-      ];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.rule = {value: {}};
-      element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
-      assert.isNotOk(element.rule.value.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
-      assert.isTrue(element.rule.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('_handleAccessSaved', () => {
-      const originalValue = {action: 'DENY'};
-      const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
-      element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
-    });
-
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
-      };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
-    });
-  });
-
-  suite('already existing generic rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'submit';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-        },
-      };
-      element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properties defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify and cancel restores original values', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      assert.isTrue(element.rule.value.modified);
-      element.editing = false;
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(element.$.action.bindValue, 'ALLOW');
-      assert.isNotOk(element.rule.value.modified);
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('all selects are disabled when not in edit mode', () => {
-      const selects = element.root.querySelectorAll('select');
-      for (const select of selects) {
-        assert.isTrue(select.disabled);
-      }
-      element.editing = true;
-      for (const select of selects) {
-        assert.isFalse(select.disabled);
-      }
-    });
-
-    test('remove rule and undo remove', () => {
-      element.editing = true;
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      assert.isFalse(
-          element.$.deletedContainer.classList.contains('deleted'));
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-    });
-
-    test('remove rule and cancel', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      MockInteractions.tap(element.$.removeBtn);
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-      assert.isNotOk(element.rule.value.modified);
-
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-    });
-
-    test('_computeGroupPath', () => {
-      const group = '123';
-      assert.equal(element._computeGroupPath(group),
-          `/admin/groups/123`);
-    });
-  });
-
-  suite('new edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('remove value', () => {
-      element.editing = true;
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(element.$.removeBtn);
-      flush();
-      assert.isTrue(removeStub.called);
-    });
-  });
-
-  suite('already existing rule with labels', () => {
-    setup(async () => {
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-          max: 2,
-          min: -2,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#labelMin').bindValue,
-          element.rule.value.min);
-      assert.equal(
-          element.root.querySelector('#labelMax').bindValue,
-          element.rule.value.max);
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify value', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-      assert.isFalse(removeStub.called);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new rule with labels', () => {
-    setup(async () => {
-      sinon.spy(element, '_setDefaultRuleValues');
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      assert.isTrue(element._setDefaultRuleValues.called);
-
-      const expectedRuleValue = {
-        max: element.label.values[element.label.values.length - 1].value,
-        min: element.label.values[0].value,
-        action: 'ALLOW',
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-            element.$.action.bindValue,
-            expectedRuleValue.action);
-        assert.equal(
-            element.root.querySelector('#labelMin').bindValue,
-            expectedRuleValue.min);
-        assert.equal(
-            element.root.querySelector('#labelMax').bindValue,
-            expectedRuleValue.max);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', async () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      await flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
new file mode 100644
index 0000000..1afd123
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,685 @@
+/**
+ * @license
+ * Copyright (C) 2017 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';
+import './gr-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+  let element: GrRuleEditor;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('unit tests', () => {
+    test('_computeForce, _computeForceClass, and _computeForceOptions', () => {
+      const ForcePushOptions = {
+        ALLOW: [
+          {name: 'Allow pushing (but not force pushing)', value: false},
+          {name: 'Allow pushing with or without force', value: true},
+        ],
+        BLOCK: [
+          {name: 'Block pushing with or without force', value: false},
+          {name: 'Block force pushing', value: true},
+        ],
+      };
+
+      const FORCE_EDIT_OPTIONS = [
+        {
+          name: 'No Force Edit',
+          value: false,
+        },
+        {
+          name: 'Force Edit',
+          value: true,
+        },
+      ];
+      let permission = 'push' as AccessPermissionId;
+      let action = 'ALLOW';
+      assert.isTrue(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission, action),
+        ForcePushOptions.ALLOW
+      );
+
+      action = 'BLOCK';
+      assert.isTrue(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission, action),
+        ForcePushOptions.BLOCK
+      );
+
+      action = 'DENY';
+      assert.isFalse(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), '');
+      assert.equal(element._computeForceOptions(permission, action).length, 0);
+
+      permission = 'editTopicName' as AccessPermissionId;
+      assert.isTrue(element._computeForce(permission));
+      assert.equal(element._computeForceClass(permission), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission),
+        FORCE_EDIT_OPTIONS
+      );
+      permission = 'submit' as AccessPermissionId;
+      assert.isFalse(element._computeForce(permission));
+      assert.equal(element._computeForceClass(permission), '');
+      assert.deepEqual(element._computeForceOptions(permission), []);
+    });
+
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(
+        element._computeSectionClass(editing, deleted),
+        'editing deleted'
+      );
+    });
+
+    test('_getDefaultRuleValues', () => {
+      let permission = 'priority' as AccessPermissionId;
+      let label;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'BATCH',
+      });
+      permission = 'label-Code-Review' as AccessPermissionId;
+      label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+        max: 2,
+        min: -2,
+      });
+      permission = 'push' as AccessPermissionId;
+      label = undefined;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+        force: false,
+      });
+      permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+      });
+    });
+
+    test('_setDefaultRuleValues', async () => {
+      element.rule = {value: {}};
+      const defaultValue = {action: 'ALLOW'};
+      const getDefaultRuleValuesStub = sinon
+        .stub(element, '_getDefaultRuleValues')
+        .returns(defaultValue);
+      element._setDefaultRuleValues();
+      assert.isTrue(getDefaultRuleValuesStub.called);
+      assert.equal(element.rule!.value, defaultValue);
+    });
+
+    test('_computeOptions', () => {
+      const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+      const DROPDOWN_OPTIONS = ['ALLOW', 'DENY', 'BLOCK'];
+      let permission = 'priority';
+      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+      permission = 'submit';
+      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.rule = {value: {}};
+      element.addEventListener('access-modified', modifiedHandler);
+      element._handleValueChange();
+      assert.isNotOk(element.rule!.value!.modified);
+      element._originalRuleValues = {};
+      element._handleValueChange();
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('_handleAccessSaved', () => {
+      const originalValue = {action: 'DENY'};
+      const newValue = {action: 'ALLOW'};
+      element._originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element._handleAccessSaved();
+      assert.deepEqual(element._originalRuleValues, newValue);
+    });
+
+    test('_setOriginalRuleValues', () => {
+      const value = {
+        action: 'ALLOW',
+        force: false,
+      };
+      element._setOriginalRuleValues(value);
+      assert.deepEqual(element._originalRuleValues, value);
+    });
+  });
+
+  suite('already existing generic rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'submit' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+
+      // Typically called on ready since elements will have properties defined
+      // by the parent element.
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify and cancel restores original values', () => {
+      element.editing = true;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = 'DENY';
+      assert.isTrue(element.rule!.value!.modified);
+      element.editing = false;
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        'ALLOW'
+      );
+      assert.isNotOk(element.rule!.value!.modified);
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = 'DENY';
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('all selects are disabled when not in edit mode', () => {
+      const selects = queryAll<HTMLSelectElement>(element, 'select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', () => {
+      element.editing = true;
+      element.rule = {value: {action: 'ALLOW'}};
+      assert.isFalse(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      assert.isTrue(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule!.value!.deleted);
+
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule!.value!.deleted);
+    });
+
+    test('remove rule and cancel', () => {
+      element.editing = true;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+
+      element.rule = {value: {action: 'ALLOW'}};
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule!.value!.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule!.value!.deleted);
+      assert.isNotOk(element.rule!.value!.modified);
+
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+    });
+
+    test('_computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element._computeGroupPath(group), '/admin/groups/123');
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('remove value', () => {
+      element.editing = true;
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      flush();
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(async () => {
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        element.rule!.value!.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        element.rule!.value!.max
+      );
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify value', () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+    setup(async () => {
+      setDefaultRuleValuesSpy = sinon.spy(element, '_setDefaultRuleValues');
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      assert.isTrue(setDefaultRuleValuesSpy.called);
+
+      const expectedRuleValue = {
+        max: element.label!.values![element.label!.values.length - 1].value,
+        min: element.label!.values![0].value,
+        action: 'ALLOW',
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+          expectedRuleValue.min
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+          expectedRuleValue.max
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index a098d03..d4b0328 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -44,8 +44,12 @@
 } from '../../../types/common';
 import {assertNever, hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {
+  getRequirements,
+  iconForStatus,
+  showNewSubmitRequirements,
+  StandardLabels,
+} from '../../../utils/label-util';
 import {SubmitRequirementStatus} from '../../../api/rest-api';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -109,17 +113,12 @@
 
   @state() private dynamicCellEndpoints?: string[];
 
-  @state() private isSubmitRequirementsUiEnabled = false;
-
   reporting: ReportingService = getAppContext().reportingService;
 
   private readonly flagsService = getAppContext().flagsService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this.isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-    );
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -200,6 +199,9 @@
         .subject:hover .content {
           text-decoration: underline;
         }
+        .requirement {
+          text-align: left;
+        }
         .u-monospace {
           font-family: var(--monospace-font-family);
           font-size: var(--font-size-mono);
@@ -226,6 +228,9 @@
         .cell.label iron-icon {
           vertical-align: top;
         }
+        .cell.label > .commentIcon {
+          color: var(--deemphasized-text-color);
+        }
         @media only screen and (max-width: 50em) {
           :host {
             display: flex;
@@ -512,6 +517,7 @@
         class="${this.computeLabelClass(labelName)}"
       >
         ${this.renderChangeHasLabelIcon(labelName)}
+        ${this.renderCommentsInfoWithLabel(labelName)}
       </td>
     `;
   }
@@ -525,6 +531,16 @@
     `;
   }
 
+  private renderCommentsInfoWithLabel(labelName: string) {
+    if (!showNewSubmitRequirements(this.flagsService, this.change)) return;
+    if (labelName !== StandardLabels.CODE_REVIEW) return;
+    if (!this.change?.unresolved_comment_count) return;
+    return html`<iron-icon
+      icon="gr-icons:comment"
+      class="commentIcon"
+    ></iron-icon>`;
+  }
+
   private renderChangePluginEndpoint(pluginEndpointName: string) {
     return html`
       <td class="cell endpoint">
@@ -572,12 +588,13 @@
   // private but used in test
   computeLabelClass(labelName: string) {
     const classes = ['cell', 'label'];
-    if (this.isSubmitRequirementsUiEnabled) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       const requirements = getRequirements(this.change).filter(
         sr => sr.name === labelName
       );
       if (requirements.length === 1) {
         const status = requirements[0].status;
+        classes.push('requirement');
         switch (status) {
           case SubmitRequirementStatus.SATISFIED:
             classes.push('u-green');
@@ -622,7 +639,7 @@
 
   // private but used in test
   computeLabelIcon(labelName: string): string {
-    if (this.isSubmitRequirementsUiEnabled) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       const requirements = getRequirements(this.change).filter(
         sr => sr.name === labelName
       );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 4b39499..7cebc11 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -15,10 +15,16 @@
  * limitations under the License.
  */
 
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
 import '../../../test/common-test-setup-karma';
 import {
   createAccountWithId,
   createChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
@@ -28,6 +34,7 @@
   RepoName,
   TopicName,
 } from '../../../types/common';
+import {StandardLabels} from '../../../utils/label-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {columnNames} from '../gr-change-list/gr-change-list';
 import './gr-change-list-item';
@@ -463,4 +470,34 @@
     assert.equal(element.computeRepoDisplay(), 'a/test/repo');
     assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
   });
+
+  test('renders requirement with new submit requirements', async () => {
+    sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+        expression: 'label:Verified=MAX -label:Verified=MIN',
+      },
+    };
+    const change: ChangeInfo = {
+      ...createChange(),
+      submit_requirements: [submitRequirement],
+      unresolved_comment_count: 1,
+    };
+    const element = await fixture<GrChangeListItem>(
+      html`<gr-change-list-item
+        .change=${change}
+        .labelNames=${[StandardLabels.CODE_REVIEW]}
+      ></gr-change-list-item>`
+    );
+
+    const requirement = queryAndAssert(element, '.requirement');
+    expect(requirement).dom.to.equal(`<iron-icon icon="gr-icons:check">
+      </iron-icon>
+      <iron-icon class="commentIcon" icon="gr-icons:comment">
+      </iron-icon>
+    `);
+  });
 });
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 5ae5271..7d618f5 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
@@ -51,9 +51,11 @@
 import {fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {PRIORITY_REQUIREMENTS_ORDER} from '../../../utils/label-util';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {
+  PRIORITY_REQUIREMENTS_ORDER,
+  showNewSubmitRequirements,
+} from '../../../utils/label-util';
+import {addGlobalShortcut, Key} from '../../../utils/dom-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -176,7 +178,7 @@
     super();
     this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursor.focusOnMove = true;
-    addShortcut(this, {key: Key.ENTER}, () => this.openChange());
+    addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
   }
 
   override ready() {
@@ -205,20 +207,22 @@
     return column.toLowerCase();
   }
 
-  @observe('account', 'preferences', '_config')
+  @observe('account', 'preferences', '_config', 'sections')
   _computePreferences(
     account?: AccountInfo,
     preferences?: PreferencesInput,
-    config?: ServerInfo
+    config?: ServerInfo,
+    sections?: ChangeListSection[]
   ) {
     if (!config) {
       return;
     }
 
+    const changes = (sections ?? []).map(section => section.results).flat();
     this.changeTableColumns = columnNames;
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(col, config, changes)
     );
     if (account && preferences) {
       this.showNumber = !!(
@@ -229,11 +233,7 @@
           column === 'Project' ? 'Repo' : column
         );
         this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(
-            col,
-            config,
-            this.flagsService.enabledExperiments
-          )
+          this._isColumnEnabled(col, config, changes)
         );
       }
     }
@@ -242,12 +242,15 @@
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  _isColumnEnabled(column: string, config: ServerInfo, changes?: ChangeInfo[]) {
     if (!columnNames.includes(column)) return false;
     if (!config || !config.change) return true;
-    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
     if (column === 'Requirements')
-      return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
+      return (changes ?? []).every(change =>
+        showNewSubmitRequirements(this.flagsService, change)
+      );
     return true;
   }
 
@@ -302,9 +305,10 @@
         labels = labels.concat(currentLabels.filter(nonExistingLabel));
       }
     }
+    const changes = sections.map(section => section.results).flat();
     if (
-      this.flagsService.enabledExperiments.includes(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      (changes ?? []).every(change =>
+        showNewSubmitRequirements(this.flagsService, change)
       )
     ) {
       labels = labels.filter(l => PRIORITY_REQUIREMENTS_ORDER.includes(l));
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 e635613..83808af 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
@@ -386,7 +386,7 @@
   // Accessed in tests
   readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly changeService = getAppContext().changeService;
+  private readonly changeModel = getAppContext().changeModel;
 
   @property({type: Object})
   change?: ChangeViewChangeInfo;
@@ -1716,7 +1716,7 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return this.changeService.fetchChangeUpdates(change).then(result => {
+    return this.changeModel.fetchChangeUpdates(change).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
           new CustomEvent<ShowAlertEventDetail>('show-alert', {
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 416bd43..d0126fd 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
@@ -87,7 +87,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {showNewSubmitRequirements} from '../../../utils/label-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -213,9 +213,6 @@
   @property({type: Object})
   queryTopic?: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _isSubmitRequirementsUiEnabled = false;
-
   restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -225,9 +222,6 @@
   override ready() {
     super.ready();
     this.queryTopic = (input: string) => this._getTopicSuggestions(input);
-    this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-    );
   }
 
   @observe('change.labels')
@@ -713,13 +707,7 @@
   }
 
   _showNewSubmitRequirements(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length > 0;
-  }
-
-  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length === 0;
+    return showNewSubmitRequirements(this.flagsService, change);
   }
 }
 
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 15631e4..46303a9 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
@@ -113,10 +113,6 @@
       --iron-icon-height: 18px;
       --iron-icon-width: 18px;
     }
-    .submit-requirement-error {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
     <div class="metadata-header">
@@ -479,11 +475,6 @@
           mutable="[[_mutable]]"
         ></gr-change-requirements>
       </template>
-      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
-        <div class="submit-requirement-error">
-          New Submit Requirements don't work on this change.
-        </div>
-      </template>
     </div>
     <section
       id="webLinks"
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index f072fa3..338c015 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -20,10 +20,6 @@
 import './gr-change-metadata';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
-  _testOnly_initGerritPluginApi,
-  GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
@@ -71,11 +67,9 @@
 const basicFixture = fixtureFromElement('gr-change-metadata');
 
 suite('gr-change-metadata tests', () => {
-  let pluginApi: GerritInternal;
   let element: GrChangeMetadata;
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getConfig').returns(
       Promise.resolve({
@@ -897,7 +891,7 @@
       }
       let hookEl: MetadataGrEndpointDecorator;
       let plugin: PluginApi;
-      pluginApi.install(
+      window.Gerrit.install(
         p => {
           plugin = p;
           plugin
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 6991c03..8161592 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
@@ -154,7 +154,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
@@ -205,7 +204,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
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 0df3a3d..02d1beb 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
@@ -20,15 +20,9 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {getAppContext} from '../../../services/app-context';
 import {
-  allRunsLatestPatchsetLatestAttempt$,
-  aPluginHasRegistered$,
   CheckResult,
   CheckRun,
   ErrorMessages,
-  errorMessagesLatest$,
-  loginCallbackLatest$,
-  someProvidersAreLoadingFirstTime$,
-  topLevelActionsLatest$,
 } from '../../../services/checks/checks-model';
 import {Action, Category, Link, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
@@ -65,11 +59,6 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {account$} from '../../../services/user/user-model';
-import {
-  changeComments$,
-  threads$,
-} from '../../../services/comments/comments-model';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -412,23 +401,55 @@
 
   private showAllChips = new Map<RunStatus | Category, boolean>();
 
-  private checksService = getAppContext().checksService;
+  private commentsModel = getAppContext().commentsModel;
+
+  private userModel = getAppContext().userModel;
+
+  private checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
-    subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
     subscribe(
       this,
-      someProvidersAreLoadingFirstTime$,
+      this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.aPluginHasRegistered$,
+      x => (this.showChecksSummary = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
-    subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
-    subscribe(this, changeComments$, x => (this.changeComments = x));
-    subscribe(this, threads$, x => (this.commentThreads = x));
-    subscribe(this, account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      this.checksModel.errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.topLevelActionsLatest$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      this.commentsModel.changeComments$,
+      x => (this.changeComments = x)
+    );
+    subscribe(
+      this,
+      this.commentsModel.threads$,
+      x => (this.commentThreads = x)
+    );
+    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
   }
 
   static override get styles() {
@@ -559,7 +580,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.checksModel.triggerAction(e.detail);
   }
 
   private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
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 7d8bb86..1d66aa5 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
@@ -141,7 +141,7 @@
   isDraftThread,
   isRobot,
   isUnresolved,
-  UIDraft,
+  DraftInfo,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
@@ -180,12 +180,10 @@
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
+import {GerritView} from '../../../services/router/router-model';
 import {
   debounce,
   DelayedTask,
-  isFalse,
   throttleWrap,
   until,
 } from '../../../utils/async-util';
@@ -193,17 +191,12 @@
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
-  changeComments$,
-  drafts$,
-} from '../../../services/comments/comments-model';
-import {
   getAddedByReason,
   getRemovedByReason,
   hasAttention,
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {preferenceDiffViewMode$} from '../../../services/user/user-model';
-import {change$, changeLoading$} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -280,13 +273,6 @@
    * @event show-auth-required
    */
 
-  // Accessed in tests.
-  readonly reporting = getAppContext().reportingService;
-
-  readonly jsAPI = getAppContext().jsApiService;
-
-  private readonly changeService = getAppContext().changeService;
-
   /**
    * URL params passed from the router.
    */
@@ -365,7 +351,7 @@
   _changeNum?: NumericChangeId;
 
   @property({type: Object})
-  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+  _diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
   @property({type: Boolean})
   _editingCommitMessage = false;
@@ -393,9 +379,6 @@
   @property({type: Object})
   _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
 
-  @property({type: Number})
-  _lineHeight?: number;
-
   @property({type: Object})
   _patchRange?: ChangeViewPatchRange;
 
@@ -560,14 +543,6 @@
   })
   resolveWeblinks?: GeneratedWebLink[];
 
-  readonly restApiService = getAppContext().restApiService;
-
-  private readonly userService = getAppContext().userService;
-
-  private readonly commentsService = getAppContext().commentsService;
-
-  private readonly shortcuts = getAppContext().shortcutsService;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -614,6 +589,27 @@
     ];
   }
 
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
+
+  readonly jsAPI = getAppContext().jsApiService;
+
+  private readonly checksModel = getAppContext().checksModel;
+
+  readonly restApiService = getAppContext().restApiService;
+
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
+
+  // Private but used in tests.
+  readonly changeModel = getAppContext().changeModel;
+
+  private readonly routerModel = getAppContext().routerModel;
+
+  private readonly commentsModel = getAppContext().commentsModel;
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
   private subscriptions: Subscription[] = [];
 
   private replyRefitTask?: DelayedTask;
@@ -633,32 +629,32 @@
   override ready() {
     super.ready();
     this.subscriptions.push(
-      aPluginHasRegistered$.subscribe(b => {
+      this.checksModel.aPluginHasRegistered$.subscribe(b => {
         this._showChecksTab = b;
       })
     );
     this.subscriptions.push(
-      routerView$.subscribe(view => {
+      this.routerModel.routerView$.subscribe(view => {
         this.isViewCurrent = view === GerritView.CHANGE;
       })
     );
     this.subscriptions.push(
-      drafts$.subscribe(drafts => {
+      this.commentsModel.drafts$.subscribe(drafts => {
         this._diffDrafts = {...drafts};
       })
     );
     this.subscriptions.push(
-      preferenceDiffViewMode$.subscribe(diffViewMode => {
+      this.userModel.preferenceDiffViewMode$.subscribe(diffViewMode => {
         this.diffViewMode = diffViewMode;
       })
     );
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this._changeComments = changeComments;
       })
     );
     this.subscriptions.push(
-      change$.subscribe(change => {
+      this.changeModel.change$.subscribe(change => {
         // The change view is tied to a specific change number, so don't update
         // _change to undefined.
         if (change) this._change = change;
@@ -790,9 +786,9 @@
 
   _handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.userModel.updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
@@ -886,6 +882,13 @@
     this._tabState = e.detail.tabState;
   }
 
+  /**
+   * Currently there is a bug in this code where this.unresolvedOnly is only
+   * assigned the correct value when _onPaperTabClick is triggered which is
+   * only triggered when user explicitly clicks on the tab however the comments
+   * tab can also be opened via the url in which case the correct value to
+   * unresolvedOnly is never assigned.
+   */
   _onPaperTabClick(e: MouseEvent) {
     let target = e.target as HTMLElement | null;
     let tabName: string | undefined;
@@ -897,7 +900,8 @@
     } while (target);
 
     if (tabName === PrimaryTab.COMMENT_THREADS) {
-      // Show unresolved threads by default only if they are present
+      // Show unresolved threads by default
+      // Show resolved threads only if no unresolved threads exist
       const hasUnresolvedThreads =
         (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
           .length > 0;
@@ -1344,7 +1348,6 @@
   _performPostLoadTasks() {
     this._maybeShowReplyDialog();
     this._maybeShowRevertDialog();
-    this._maybeShowDownloadDialog();
 
     this._sendShowChangeEvent();
 
@@ -1459,13 +1462,6 @@
     });
   }
 
-  _maybeShowDownloadDialog() {
-    if (this.viewState.showDownloadDialog) {
-      this._handleOpenDownloadDialog();
-      this.set('viewState.showDownloadDialog', false);
-    }
-  }
-
   _resetFileListViewState() {
     this.set('viewState.selectedFileIndex', 0);
     if (
@@ -1538,7 +1534,7 @@
   }
 
   _computeReplyButtonLabel(
-    drafts?: {[path: string]: UIDraft[]},
+    drafts?: {[path: string]: DraftInfo[]},
     canStartReview?: boolean
   ) {
     if (drafts === undefined || canStartReview === undefined) {
@@ -1887,7 +1883,10 @@
       throw new Error('missing required changeNum property');
     }
 
-    const detailCompletes = until(changeLoading$, isFalse);
+    const detailCompletes = until(
+      this.changeModel.changeLoadingStatus$,
+      status => status === LoadingStatus.LOADED
+    );
     const editCompletes = this._getEdit();
     const prefCompletes = this._getPreferences();
 
@@ -1919,11 +1918,6 @@
           this._latestCommitMessage = null;
         }
 
-        const lineHeight = getComputedStyle(this).lineHeight;
-
-        // Slice returns a number as a string, convert to an int.
-        this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
-
         this.computeRevertSubmitted(this._change);
         if (
           !this._patchRange ||
@@ -2223,13 +2217,13 @@
     const promises = [this._getCommitInfo(), this.$.fileList.reload()];
     if (patchNumChanged) {
       promises.push(
-        this.commentsService.reloadPortedComments(
+        this.commentsModel.reloadPortedComments(
           this._changeNum,
           this._patchRange?.patchNum
         )
       );
       promises.push(
-        this.commentsService.reloadPortedDrafts(
+        this.commentsModel.reloadPortedDrafts(
           this._changeNum,
           this._patchRange?.patchNum
         )
@@ -2334,13 +2328,12 @@
     }
 
     this._updateCheckTimerHandle = window.setTimeout(() => {
-      if (!this.isViewCurrent) {
+      if (!this.isViewCurrent || !this._change) {
         this._startUpdateCheckTimer();
         return;
       }
-      assertIsDefined(this._change, '_change');
       const change = this._change;
-      this.changeService.fetchChangeUpdates(change).then(result => {
+      this.changeModel.fetchChangeUpdates(change).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
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 eea6d3a..13fb5b3 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
@@ -565,10 +565,6 @@
         <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          account="[[_account]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
           unresolved-only="[[unresolvedOnly]]"
@@ -597,14 +593,7 @@
           value="[[_currentRobotCommentsPatchSet]]"
         >
         </gr-dropdown-list>
-        <gr-thread-list
-          threads="[[_robotCommentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          hide-dropdown
-          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-        >
+        <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
         </gr-thread-list>
         <template is="dom-if" if="[[_showRobotCommentsButton]]">
           <gr-button
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 6104356..e8cce2d 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
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma';
 import '../../edit/gr-edit-constants';
+import '../gr-thread-list/gr-thread-list';
 import './gr-change-view';
 import {
   ChangeStatus,
@@ -27,16 +28,11 @@
   MessageTag,
   PrimaryTab,
   createDefaultPreferences,
-  createDefaultDiffPrefs,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
-  _testOnly_initGerritPluginApi,
-  GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
@@ -91,6 +87,7 @@
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
+  RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
@@ -102,22 +99,21 @@
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {_testOnly_setState as setUserState} from '../../../services/user/user-model';
-import {_testOnly_setState as setChangeState} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 
 const fixture = fixtureFromElement('gr-change-view');
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
-  let pluginApi: GerritInternal;
 
   let navigateToChangeStub: SinonStubbedMember<
     typeof GerritNav.navigateToChange
@@ -167,8 +163,6 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -271,8 +265,6 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -347,7 +339,6 @@
   ];
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     // Since pluginEndpoints are global, must reset state.
     _testOnly_resetEndpoints();
     navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -371,7 +362,7 @@
     element._changeNum = TEST_NUMERIC_CHANGE_ID;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => {
         plugin.registerDynamicCustomComponent(
           'change-view-tab-header',
@@ -818,10 +809,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      setUserState({
-        preferences: prefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
+      element.userModel.setPreferences(prefs);
       element._handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -831,10 +819,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      setUserState({
-        preferences: newPrefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
+      element.userModel.setPreferences(newPrefs);
       await flush();
       element._handleToggleDiffMode();
       assert.isTrue(
@@ -879,7 +864,43 @@
     });
   });
 
-  suite('Findings comment tab', () => {
+  suite('Comments tab', () => {
+    setup(async () => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+          rev4: createRevision(4),
+        },
+        current_revision: 'rev4' as CommitId,
+      };
+      element._commentThreads = THREADS;
+      await flush();
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[1]);
+      await flush();
+    });
+
+    test('commentId overrides unresolveOnly default', async () => {
+      const threadList = queryAndAssert<GrThreadList>(
+        element,
+        'gr-thread-list'
+      );
+      assert.isTrue(element.unresolvedOnly);
+      assert.isNotOk(element.scrollCommentId);
+      assert.isTrue(threadList.unresolvedOnly);
+
+      element.scrollCommentId = 'abcd' as UrlEncodedCommentId;
+      await flush();
+      assert.isFalse(threadList.unresolvedOnly);
+    });
+  });
+
+  suite('Findings robot-comment tab', () => {
     setup(async () => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._change = {
@@ -925,11 +946,13 @@
     test('only robot comments are rendered', () => {
       assert.equal(element._robotCommentThreads!.length, 2);
       assert.equal(
-        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc1'
       );
       assert.equal(
-        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc2'
       );
     });
@@ -1498,7 +1521,8 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
         labels: {},
@@ -1513,7 +1537,8 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
         labels: {},
@@ -1529,7 +1554,8 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    setChangeState({
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
         labels: {},
@@ -1745,6 +1771,7 @@
         '#replyDialog'
       );
       const openSpy = sinon.spy(dialog, 'open');
+      await flush();
       await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
       assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
@@ -1948,7 +1975,8 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    setChangeState({
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
         revisions: {
@@ -1978,7 +2006,8 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    setChangeState({
+    element.changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
         revisions: {
@@ -2143,7 +2172,11 @@
       element._change = {...createChangeViewChange(), labels: {}};
       element._selectedRevision = createRevision();
       const promise = mockPromise();
-      pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
       await flush();
       const plugin: PluginApi = (await promise) as PluginApi;
       const hookEl = await plugin
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index a9b7b81..c24e054 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -29,8 +29,7 @@
 import {customElement, property, query, state} from 'lit/decorators';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {change$} from '../../../services/change/change-model';
-import {threads$} from '../../../services/comments/comments-model';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 
 @customElement('gr-confirm-submit-dialog')
@@ -62,6 +61,10 @@
   @state()
   initialised = false;
 
+  private commentsModel = getAppContext().commentsModel;
+
+  private changeModel = getAppContext().changeModel;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -90,10 +93,10 @@
 
   constructor() {
     super();
-    subscribe(this, change$, x => (this.change = x));
+    subscribe(this, this.changeModel.change$, x => (this.change = x));
     subscribe(
       this,
-      threads$,
+      this.commentsModel.threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
@@ -125,9 +128,6 @@
       <gr-thread-list
         id="commentList"
         .threads="${this.unresolvedThreads}"
-        .change="${this.change}"
-        .changeNum="${this.change?._number}"
-        logged-in
         hide-dropdown
       >
       </gr-thread-list>
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 f400814..112077a 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
@@ -83,12 +83,8 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {
-  diffPreferences$,
-  sizeBarInChangeTable$,
-} from '../../../services/user/user-model';
-import {changeComments$} from '../../../services/comments/comments-model';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {select} from '../../../utils/observable-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -317,7 +313,9 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly commentsModel = getAppContext().commentsModel;
 
   private readonly browserModel = getAppContext().browserModel;
 
@@ -377,26 +375,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+    this.subscriptions = [
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
-      })
-    );
-    this.subscriptions.push(
+      }),
       this.browserModel.diffViewMode$.subscribe(
         diffView => (this.diffViewMode = diffView)
-      )
-    );
-    this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      ),
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this.diffPrefs = diffPreferences;
-      })
-    );
-    this.subscriptions.push(
-      sizeBarInChangeTable$.subscribe(sizeBarInChangeTable => {
+      }),
+      select(
+        this.userModel.preferences$,
+        prefs => !!prefs?.size_bar_in_change_table
+      ).subscribe(sizeBarInChangeTable => {
         this._showSizeBars = sizeBarInChangeTable;
-      })
-    );
+      }),
+    ];
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -1648,7 +1643,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
+    this.userModel.getDiffPreferences();
   }
 
   /**
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 817c21b..fd6b0d4 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
@@ -1469,18 +1469,11 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      sinon.stub(diff.changeComments, 'getCommentsForPath')
-          .withArgs('/COMMIT_MSG', {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          })
-          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
     async function renderAndGetNewDiffs(index) {
-      const diffs =
-          element.root.querySelectorAll('gr-diff-host');
+      const diffs = element.root.querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
         await setupDiff(diffs[i]);
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 3c4702c..be04e6e 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
@@ -33,10 +33,13 @@
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {getAppContext} from '../../../services/app-context';
-import {getTriggerVotes, labelCompare} from '../../../utils/label-util';
+import {
+  getTriggerVotes,
+  labelCompare,
+  showNewSubmitRequirements,
+} from '../../../utils/label-util';
 import {Execution} from '../../../constants/reporting';
 import {ChangeStatus} from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {fontStyles} from '../../../styles/gr-font-styles';
 
 @customElement('gr-label-scores')
@@ -90,7 +93,7 @@
   }
 
   override render() {
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       return this.renderNewSubmitRequirements();
     } else {
       return this.renderOldSubmitRequirements();
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 a512b60..5d74d07 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -555,8 +555,10 @@
 
   @observe('projectName')
   _projectNameChanged(name?: string) {
-    // Check if name is undefined to prevent errors.
-    if (!name) return;
+    if (!name) {
+      this._projectConfig = undefined;
+      return;
+    }
     this.restApiService.getProjectConfig(name as RepoName).then(config => {
       this._projectConfig = config;
     });
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 7f3e9de..8def279 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
@@ -280,13 +280,11 @@
               </div>
             </template>
             <gr-thread-list
-              change="[[change]]"
               hidden$="[[!commentThreads.length]]"
               threads="[[commentThreads]]"
-              change-num="[[changeNum]]"
-              logged-in="[[_loggedIn]]"
               hide-dropdown
               show-comment-context
+              message-id="[[message.id]]"
             >
             </gr-thread-list>
           </template>
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 bd51aab..36d0ce6 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
@@ -51,13 +51,6 @@
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
 } from '../../../types/types';
-import {threads$} from '../../../services/comments/comments-model';
-import {
-  change$,
-  changeNum$,
-  repo$,
-} from '../../../services/change/change-model';
-import {loggedIn$} from '../../../services/user/user-model';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -101,17 +94,10 @@
   message: CombinedMessage,
   allThreadsForChange: CommentThread[]
 ): CommentThread[] {
-  if (message._index === undefined) {
-    return [];
-  }
+  if (message._index === undefined) return [];
   const messageId = getMessageId(message);
   return allThreadsForChange.filter(thread =>
-    thread.comments.some(comment => {
-      const matchesMessage = comment.change_message_id === messageId;
-      if (!matchesMessage) return false;
-      comment.collapsed = !matchesMessage;
-      return matchesMessage;
-    })
+    thread.comments.some(comment => comment.change_message_id === messageId)
   );
 }
 
@@ -263,6 +249,13 @@
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
   _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
+  private readonly userModel = getAppContext().userModel;
+
+  // Private but used in tests.
+  readonly commentsModel = getAppContext().commentsModel;
+
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = getAppContext().shortcutsService;
@@ -272,27 +265,27 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
-      threads$.subscribe(x => {
+      this.commentsModel.threads$.subscribe(x => {
         this.commentThreads = x;
       })
     );
     this.subscriptions.push(
-      change$.subscribe(x => {
+      this.changeModel.change$.subscribe(x => {
         this.change = x;
       })
     );
     this.subscriptions.push(
-      loggedIn$.subscribe(x => {
+      this.userModel.loggedIn$.subscribe(x => {
         this.showReplyButtons = x;
       })
     );
     this.subscriptions.push(
-      repo$.subscribe(x => {
+      this.changeModel.repo$.subscribe(x => {
         this.projectName = x;
       })
     );
     this.subscriptions.push(
-      changeNum$.subscribe(x => {
+      this.changeModel.changeNum$.subscribe(x => {
         this.changeNum = x;
       })
     );
@@ -391,13 +384,6 @@
       }
     }
 
-    // collapse all by default
-    for (const thread of commentThreads) {
-      for (const comment of thread.comments) {
-        comment.collapsed = true;
-      }
-    }
-
     for (let i = 0; i < combinedMessages.length; i++) {
       const message = combinedMessages[i];
       if (message.expanded === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index 50e1a38..65c3c74 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,7 +22,6 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
-import {updateStateComments} from '../../../services/comments/comments-model.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -129,7 +128,7 @@
   };
 
   suite('basic tests', () => {
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve(comments));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -140,9 +139,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      updateStateComments(comments);
+      await element.commentsModel.reloadComments();
       element.messages = messages;
-      flush();
+      await flush();
     });
 
     test('expand/collapse all', () => {
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 7ab6108..c882764 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
@@ -620,7 +620,10 @@
         this.restApiService.getConfig().then(config => {
           if (config && !config.change.submit_whole_topic) {
             return this.restApiService
-              .getChangesWithSameTopic(changeTopic, change._number)
+              .getChangesWithSameTopic(changeTopic, {
+                openChangesOnly: true,
+                changeToExclude: change._number,
+              })
               .then(response => {
                 if (changeTopic === this.change?.topic) {
                   this.sameTopicChanges = response ?? [];
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 68d48b7..94b8668 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -47,10 +47,6 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {
-  _testOnly_initGerritPluginApi,
-  GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
 import {
@@ -64,10 +60,8 @@
 
 suite('gr-related-changes-list', () => {
   let element: GrRelatedChangesList;
-  let pluginApi: GerritInternal;
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     element = basicFixture.instantiate();
   });
 
@@ -609,7 +603,7 @@
       }
       let hookEl: RelatedChangesListGrEndpointDecorator;
       let plugin: PluginApi;
-      pluginApi.install(
+      window.Gerrit.install(
         p => {
           plugin = p;
           plugin
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 8980642..8e78d4e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -19,14 +19,12 @@
 import './gr-reply-dialog.js';
 
 import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
 suite('gr-reply-dialog-it tests', () => {
   let element;
-  let pluginApi;
   let changeNum;
   let patchNum;
 
@@ -71,7 +69,6 @@
   };
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     changeNum = 42;
     patchNum = 1;
 
@@ -102,7 +99,7 @@
 
   test('lgtm plugin', async () => {
     resetPlugins();
-    pluginApi.install(plugin => {
+    window.Gerrit.install(plugin => {
       const replyApi = plugin.changeReply();
       replyApi.addReplyTextChangedCallback(text => {
         const label = 'Code-Review';
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 dca0d65..52a5dac 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
@@ -218,7 +218,7 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeService = getAppContext().changeService;
+  private readonly changeModel = getAppContext().changeModel;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -435,7 +435,7 @@
   open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.changeService.fetchChangeUpdates(this.change).then(result => {
+    this.changeModel.fetchChangeUpdates(this.change).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 4a8b996..719347c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -394,9 +394,6 @@
         id="commentList"
         hidden$="[[!_includeComments]]"
         threads="[[draftCommentThreads]]"
-        change="[[change]]"
-        change-num="[[change._number]]"
-        logged-in="true"
         hide-dropdown=""
       >
       </gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index df28175..14ad4ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -35,6 +35,7 @@
 import {
   createAccountWithId,
   createChange,
+  createComment,
   createCommentThread,
   createDraft,
   createRevision,
@@ -318,18 +319,13 @@
     if (hasDraft) {
       draftThreads = [
         {
-          ...createCommentThread([
-            {
-              ...createDraft(),
-              __draft: true,
-              unresolved: true,
-            },
-          ]),
+          ...createCommentThread([{...createDraft(), unresolved: true}]),
         },
       ];
     }
     replyToIds?.forEach(id =>
       draftThreads[0].comments.push({
+        ...createComment(),
         author: {_account_id: id},
       })
     );
@@ -878,11 +874,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '1' as UrlEncodedCommentId,
             author: {_account_id: 1 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '2' as UrlEncodedCommentId,
             in_reply_to: '1' as UrlEncodedCommentId,
             author: {_account_id: 2 as AccountId},
@@ -893,11 +891,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '3' as UrlEncodedCommentId,
             author: {_account_id: 3 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '4' as UrlEncodedCommentId,
             in_reply_to: '3' as UrlEncodedCommentId,
             author: {_account_id: 4 as AccountId},
@@ -2003,7 +2003,7 @@
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
+          {...createCommentThread([createComment()])},
         ],
         /* text= */ '',
         /* reviewersMutated= */ false,
@@ -2023,7 +2023,7 @@
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
+          {...createCommentThread([createComment()])},
         ],
         /* text= */ '',
         /* reviewersMutated= */ false,
@@ -2042,7 +2042,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ 'test',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ false,
@@ -2060,7 +2062,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ true,
         /* labelsChanged= */ false,
@@ -2078,7 +2082,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ true,
@@ -2096,7 +2102,9 @@
     assert.isTrue(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ true,
@@ -2120,7 +2128,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ false,
@@ -2144,7 +2154,12 @@
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
@@ -2167,7 +2182,12 @@
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index e5f81b3..38230e2 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -109,11 +109,12 @@
         .expression {
           color: var(--gray-foreground);
         }
-        iron-icon.check {
+        iron-icon.check,
+        iron-icon.overridden {
           color: var(--success-foreground);
         }
         iron-icon.close {
-          color: var(--warning-foreground);
+          color: var(--error-foreground);
         }
         .showConditions iron-icon {
           color: inherit;
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index ccde670..3fc2e0f 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -18,6 +18,7 @@
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
 import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
 import '../gr-change-summary/gr-change-summary';
+import '../../shared/gr-limited-text/gr-limited-text';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {notUndefined, ParsedChangeInfo} from '../../../types/types';
@@ -43,10 +44,7 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {charsOnly} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
-import {
-  allRunsLatestPatchsetLatestAttempt$,
-  CheckRun,
-} from '../../../services/checks/checks-model';
+import {CheckRun} from '../../../services/checks/checks-model';
 import {
   firstPrimaryLink,
   getResultsOf,
@@ -56,6 +54,7 @@
 import '../../shared/gr-vote-chip/gr-vote-chip';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import {PrimaryTab} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -148,9 +147,15 @@
     ];
   }
 
+  private readonly checksModel = getAppContext().checksModel;
+
   constructor() {
     super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+    subscribe(
+      this,
+      this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
   }
 
   override render() {
@@ -220,7 +225,8 @@
               name="requirement"
               .value=${requirement}
             ></gr-endpoint-param>
-            ${this.renderVotes(requirement)}${this.renderChecks(requirement)}
+            ${this.renderVotesAndChecksChips(requirement)}
+            ${this.renderOverrideLabels(requirement)}
           </gr-endpoint-decorator>
         </td>
       </tr>
@@ -237,7 +243,7 @@
     ></iron-icon>`;
   }
 
-  renderVotes(requirement: SubmitRequirementResultInfo) {
+  renderVotesAndChecksChips(requirement: SubmitRequirementResultInfo) {
     const requirementLabels = extractAssociatedLabels(requirement);
     const allLabels = this.change?.labels ?? {};
     const associatedLabels = Object.keys(allLabels).filter(label =>
@@ -247,11 +253,17 @@
     const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
       label => !hasVotes(allLabels[label])
     );
-    if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
 
-    return associatedLabels.map(label =>
+    const checksChips = this.renderChecks(requirement);
+
+    if (everyAssociatedLabelsIsWithoutVotes) {
+      return checksChips || html`No votes`;
+    }
+
+    return html`${associatedLabels.map(label =>
       this.renderLabelVote(label, allLabels)
-    );
+    )}
+    ${checksChips}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -288,27 +300,39 @@
       (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
       0
     );
-    if (runsCount > 0) {
-      const allPrimaryLinks = requirementRuns
-        .map(run => run.results ?? [])
-        .flat()
-        .map(firstPrimaryLink)
-        .filter(notUndefined);
-      const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
-      return html`<gr-checks-chip
-        .text=${`${runsCount}`}
-        .links=${links}
-        .statusOrCategory=${Category.ERROR}
-        @click="${() => {
-          fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
-            checksTab: {
-              statusOrCategory: Category.ERROR,
-            },
-          });
-        }}"
-      ></gr-checks-chip>`;
-    }
-    return;
+    if (runsCount === 0) return;
+    const allPrimaryLinks = requirementRuns
+      .map(run => run.results ?? [])
+      .flat()
+      .map(firstPrimaryLink)
+      .filter(notUndefined);
+    const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
+    return html`<gr-checks-chip
+      .text=${`${runsCount}`}
+      .links=${links}
+      .statusOrCategory=${Category.ERROR}
+      @click="${() => {
+        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+          checksTab: {
+            statusOrCategory: Category.ERROR,
+          },
+        });
+      }}"
+    ></gr-checks-chip>`;
+  }
+
+  renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+    if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
+    const requirementLabels = extractAssociatedLabels(
+      requirement,
+      'onlyOverride'
+    ).filter(label => {
+      const allLabels = this.change?.labels ?? {};
+      return allLabels[label] && hasVotes(allLabels[label]);
+    });
+    return requirementLabels.map(
+      label => html`<span class="overrideLabel">${label}</span>`
+    );
   }
 
   renderTriggerVotes() {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
new file mode 100644
index 0000000..794d7cca9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @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';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-submit-requirements';
+import {GrSubmitRequirements} from './gr-submit-requirements';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-submit-requirements tests', () => {
+  let element: GrSubmitRequirements;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+        expression: 'label:Verified=MAX -label:Verified=MIN',
+      },
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      submit_requirements: [submitRequirement],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    element = await fixture<GrSubmitRequirements>(
+      html`<gr-submit-requirements
+        .change=${change}
+        .account=${account}
+      ></gr-submit-requirements>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(`<h3
+      class="heading-3 metadata-title"
+      id="submit-requirements-caption"
+    >
+      Submit Requirements
+    </h3>
+    <table
+      aria-labelledby="submit-requirements-caption"
+      class="requirements"
+    >
+      <thead hidden="">
+        <tr>
+          <th>Status</th>
+          <th>Name</th>
+          <th>Votes</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr id="requirement-Verified">
+          <td>
+            <iron-icon
+              aria-label="satisfied"
+              class="check"
+              icon="gr-icons:check"
+              role="img"
+            >
+            </iron-icon>
+          </td>
+          <td class="name">
+            <gr-limited-text class="name" limit="25"></gr-limited-text>
+          </td>
+          <td>
+            <gr-endpoint-decorator
+              class="votes-cell"
+              name="submit-requirement-verified"
+            >
+              <gr-endpoint-param name="change"></gr-endpoint-param>
+              <gr-endpoint-param name="requirement"></gr-endpoint-param>
+              <gr-vote-chip></gr-vote-chip>
+            </gr-endpoint-decorator>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <gr-submit-requirement-hovercard for="requirement-Verified">
+    </gr-submit-requirement-hovercard>
+  `);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 87c62b1..490162b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -17,50 +17,36 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
-  PolymerSpliceChange,
-  PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
 import {
   AccountDetailInfo,
   AccountInfo,
-  ChangeInfo,
   NumericChangeId,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {
   CommentThread,
-  isDraft,
-  isUnresolved,
+  getCommentAuthors,
+  hasHumanReply,
   isDraftThread,
   isRobotThread,
-  hasHumanReply,
-  getCommentAuthors,
-  computeId,
-  UIComment,
+  isUnresolved,
+  lastUpdated,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
-  thread: CommentThread;
-  hasRobotComment: boolean;
-  hasHumanReplyToRobotComment: boolean;
-  unresolved: boolean;
-  isEditing: boolean;
-  hasDraft: boolean;
-  updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -69,573 +55,516 @@
 
 export const __testOnly_SortDropdownState = SortDropdownState;
 
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+/**
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ */
+export function compareThreads(
+  c1: CommentThread,
+  c2: CommentThread,
+  byTimestamp = false
+) {
+  if (byTimestamp) {
+    const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+    const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+    const timeDiff = c2Time - c1Time;
+    if (timeDiff !== 0) return c2Time - c1Time;
   }
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  if (c1.path !== c2.path) {
+    // '/PATCHSET' will not come before '/COMMIT' when sorting
+    // alphabetically so move it to the front explicitly
+    if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return -1;
+    }
+    if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 1;
+    }
+    return c1.path.localeCompare(c2.path);
+  }
 
+  // Convert 'FILE' and 'LOST' to undefined.
+  const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+  const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+  if (line1 !== line2) {
+    // one of them is a FILE/LOST comment, show first
+    if (line1 === undefined) return -1;
+    if (line2 === undefined) return 1;
+    // Lower line numbers first.
+    return line1 < line2 ? -1 : 1;
+  }
+
+  if (c1.patchNum !== c2.patchNum) {
+    // `patchNum` should be required, but show undefined first.
+    if (c1.patchNum === undefined) return -1;
+    if (c2.patchNum === undefined) return 1;
+    // Higher patchset numbers first.
+    return c1.patchNum > c2.patchNum ? -1 : 1;
+  }
+
+  // Sorting should not be based on the thread being unresolved or being a draft
+  // thread, because that would be a surprising re-sort when the thread changes
+  // state.
+
+  const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+  const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+  if (c2Time !== c1Time) {
+    // Newer comments first.
+    return c2Time - c1Time;
+  }
+
+  return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+  @queryAll('gr-comment-thread')
+  threadElements?: NodeList;
+
+  /**
+   * Raw list of threads for the component to show.
+   *
+   * ATTENTION! this.threads should never be used directly within the component.
+   *
+   * Either use getAllThreads(), which applies filters that are inherent to what
+   * the component is supposed to render,
+   * e.g. onlyShowRobotCommentsWithHumanReply.
+   *
+   * Or use getDisplayedThreads(), which applies the currently selected filters
+   * on top.
+   */
   @property({type: Array})
   threads: CommentThread[] = [];
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Array})
-  _sortedThreads: CommentThread[] = [];
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-comment-context'})
   showCommentContext = false;
 
-  @property({
-    computed:
-      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
-    type: Array,
-  })
-  _displayedThreads: CommentThread[] = [];
-
-  // thread-list is used in multiple places like the change log, hence
-  // keeping the default to be false. When used in comments tab, it's
-  // set as true.
-  @property({type: Boolean})
+  /** Along with `draftsOnly` is the currently selected filter. */
+  @property({type: Boolean, attribute: 'unresolved-only'})
   unresolvedOnly = false;
 
-  @property({type: Boolean})
-  _draftsOnly = false;
-
-  @property({type: Boolean})
+  @property({
+    type: Boolean,
+    attribute: 'only-show-robot-comments-with-human-reply',
+  })
   onlyShowRobotCommentsWithHumanReply = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-dropdown'})
   hideDropdown = false;
 
-  @property({type: Object, observer: '_commentTabStateChange'})
+  @property({type: Object, attribute: 'comment-tab-state'})
   commentTabState?: CommentTabState;
 
-  @property({type: Object})
-  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
-  @property({type: Array, notify: true})
-  selectedAuthors: AccountInfo[] = [];
-
-  @property({type: Object})
-  account?: AccountDetailInfo;
-
-  @computed('unresolvedOnly', '_draftsOnly')
-  get commentsDropdownValue() {
-    // set initial value and triggered when comment summary chips are clicked
-    if (this._draftsOnly) return CommentTabState.DRAFTS;
-    return this.unresolvedOnly
-      ? CommentTabState.UNRESOLVED
-      : CommentTabState.SHOW_ALL;
-  }
-
-  @property({type: String})
+  @property({type: String, attribute: 'scroll-comment-id'})
   scrollCommentId?: UrlEncodedCommentId;
 
-  _showEmptyThreadsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    if (!threads || !displayedThreads) return false;
-    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  /**
+   * Optional context information when threads are being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  change?: ParsedChangeInfo;
+
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  selectedAuthors: AccountInfo[] = [];
+
+  @state()
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  /** Along with `unresolvedOnly` is the currently selected filter. */
+  @state()
+  draftsOnly = false;
+
+  private readonly changeModel = getAppContext().changeModel;
+
+  private readonly userModel = getAppContext().userModel;
+
+  constructor() {
+    super();
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.changeModel.change$, x => (this.change = x));
+    subscribe(this, this.userModel.account$, x => (this.account = x));
   }
 
-  _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments' : 'No unresolved comments';
+  override willUpdate(changed: PropertyValues) {
+    if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
+    if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate();
   }
 
-  _showPartyPopper(threads: CommentThread[]) {
-    return !!threads.length;
-  }
-
-  _computeResolvedCommentsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean,
-    onlyShowRobotCommentsWithHumanReply: boolean
-  ) {
-    if (onlyShowRobotCommentsWithHumanReply) {
-      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+  private onCommentTabStateUpdate() {
+    switch (this.commentTabState) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      case CommentTabState.SHOW_ALL:
+        this.handleAllComments();
+        break;
     }
-    if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return `Show ${pluralize(threads.length, 'resolved comment')}`;
-    }
-    return '';
   }
 
-  _showResolvedCommentsButton(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    return unresolvedOnly && threads.length && !displayedThreads.length;
+  /**
+   * When user wants to scroll to a comment, render all comments so that the
+   * appropriate comment can be scrolled into view.
+   */
+  private onScrollCommentIdUpdate() {
+    if (this.scrollCommentId) this.handleAllComments();
   }
 
-  _handleResolvedCommentsMessageClick() {
-    this.unresolvedOnly = !this.unresolvedOnly;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #threads {
+          display: block;
+        }
+        gr-comment-thread {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          border-bottom: 1px solid var(--border-color);
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          justify-content: left;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+        .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+        .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+          display: block;
+        }
+        .thread-separator {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-xl);
+        }
+        .show-resolved-comments {
+          box-shadow: none;
+          padding-left: var(--spacing-m);
+        }
+        .partypopper {
+          margin-right: var(--spacing-s);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--primary-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        .filter-text,
+        .sort-text,
+        .author-text {
+          margin-right: var(--spacing-s);
+          color: var(--deemphasized-text-color);
+        }
+        .author-text {
+          margin-left: var(--spacing-m);
+        }
+        gr-account-label {
+          --account-max-length: 120px;
+          display: inline-block;
+          user-select: none;
+          --label-border-radius: 8px;
+          margin: 0 var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-m);
+          line-height: var(--line-height-normal);
+          cursor: pointer;
+        }
+        gr-account-label:focus {
+          outline: none;
+        }
+        gr-account-label:hover,
+        gr-account-label:hover {
+          box-shadow: var(--elevation-level-1);
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  getSortDropdownEntires() {
+  override render() {
+    return html`
+      ${this.renderDropdown()}
+      <div id="threads" part="threads">
+        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+      </div>
+    `;
+  }
+
+  private renderDropdown() {
+    if (this.hideDropdown) return;
+    return html`
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list
+          id="sortDropdown"
+          .value="${this.sortDropdownValue}"
+          @value-change="${(e: CustomEvent) =>
+            (this.sortDropdownValue = e.detail.value)}"
+          .items="${this.getSortDropdownEntries()}"
+        >
+        </gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list
+          id="filterDropdown"
+          .value="${this.getCommentsDropdownValue()}"
+          @value-change="${this.handleCommentsDropdownValueChange}"
+          .items="${this.getCommentsDropdownEntries()}"
+        >
+        </gr-dropdown-list>
+        ${this.renderAuthorChips()}
+      </div>
+    `;
+  }
+
+  private renderEmptyThreadsMessage() {
+    const threads = this.getAllThreads();
+    const threadsEmpty = threads.length === 0;
+    const displayedEmpty = this.getDisplayedThreads().length === 0;
+    if (!displayedEmpty) return;
+    const showPopper = this.unresolvedOnly && !threadsEmpty;
+    const popper = html`<span class="partypopper">&#x1F389;</span>`;
+    const showButton = this.unresolvedOnly && !threadsEmpty;
+    const button = html`
+      <gr-button
+        class="show-resolved-comments"
+        link
+        @click="${this.handleAllComments}"
+        >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+      >
+    `;
+    return html`
+      <div>
+        <span>
+          ${showPopper ? popper : undefined}
+          ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+          ${showButton ? button : undefined}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderCommentThreads() {
+    const threads = this.getDisplayedThreads();
+    return repeat(
+      threads,
+      thread => thread.rootId,
+      (thread, index) => {
+        const isFirst =
+          index === 0 || threads[index - 1].path !== threads[index].path;
+        const separator =
+          index !== 0 && isFirst
+            ? html`<div class="thread-separator"></div>`
+            : undefined;
+        const commentThread = this.renderCommentThread(thread, isFirst);
+        return html`${separator}${commentThread}`;
+      }
+    );
+  }
+
+  private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+    return html`
+      <gr-comment-thread
+        .thread="${thread}"
+        show-file-path
+        ?show-ported-comment="${thread.ported}"
+        ?show-comment-context="${this.showCommentContext}"
+        ?show-file-name="${isFirst}"
+        .messageId="${this.messageId}"
+        ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
+        @comment-thread-editing-changed="${() => {
+          this.requestUpdate();
+        }}"
+      ></gr-comment-thread>
+    `;
+  }
+
+  private renderAuthorChips() {
+    const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+    if (authors.length === 0) return;
+    return html`<span class="author-text">From:</span>${authors.map(author =>
+        this.renderAccountChip(author)
+      )}`;
+  }
+
+  private renderAccountChip(account: AccountInfo) {
+    const selected = this.selectedAuthors.some(
+      a => a._account_id === account._account_id
+    );
+    return html`
+      <gr-account-label
+        .account="${account}"
+        @click="${this.handleAccountClicked}"
+        selectionChipStyle
+        ?selected="${selected}"
+      ></gr-account-label>
+    `;
+  }
+
+  private getCommentsDropdownValue() {
+    if (this.draftsOnly) return CommentTabState.DRAFTS;
+    if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+    return CommentTabState.SHOW_ALL;
+  }
+
+  private getSortDropdownEntries() {
     return [
       {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
       {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
     ];
   }
 
-  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
-    const items: DropdownItem[] = [
-      {
-        text: `Unresolved (${this._countUnresolved(threads)})`,
-        value: CommentTabState.UNRESOLVED,
-      },
-      {
-        text: `All (${this._countAllThreads(threads)})`,
-        value: CommentTabState.SHOW_ALL,
-      },
-    ];
-    if (loggedIn)
-      items.splice(1, 0, {
-        text: `Drafts (${this._countDrafts(threads)})`,
+  // private, but visible for testing
+  getCommentsDropdownEntries() {
+    const items: DropdownItem[] = [];
+    const threads = this.getAllThreads();
+    items.push({
+      text: `Unresolved (${threads.filter(isUnresolved).length})`,
+      value: CommentTabState.UNRESOLVED,
+    });
+    if (this.account) {
+      items.push({
+        text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
       });
+    }
+    items.push({
+      text: `All (${threads.length})`,
+      value: CommentTabState.SHOW_ALL,
+    });
     return items;
   }
 
-  getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
-    return getCommentAuthors(threads, account);
-  }
-
-  handleAccountClicked(e: MouseEvent) {
+  private handleAccountClicked(e: MouseEvent) {
     const account = (e.target as GrAccountChip).account;
     assertIsDefined(account, 'account');
-    const index = this.selectedAuthors.findIndex(
-      author => author._account_id === account._account_id
-    );
-    if (index === -1) this.push('selectedAuthors', account);
-    else this.splice('selectedAuthors', index, 1);
-    // re-assign so that isSelected template method is called
-    this.selectedAuthors = [...this.selectedAuthors];
+    const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+    const found = this.selectedAuthors.find(predicate);
+    if (found) {
+      this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+    } else {
+      this.selectedAuthors = [...this.selectedAuthors, account];
+    }
   }
 
-  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
-    return selectedAuthors.some(a => a._account_id === author._account_id);
-  }
-
-  computeShouldScrollIntoView(
-    comments: UIComment[],
-    scrollCommentId?: UrlEncodedCommentId
-  ) {
-    const comment = comments?.[0];
-    if (!comment) return false;
-    return computeId(comment) === scrollCommentId;
-  }
-
-  handleSortDropdownValueChange(e: CustomEvent) {
-    this.sortDropdownValue = e.detail.value;
-    /*
-     * Ideally we would have updateSortedThreads observe on sortDropdownValue
-     * but the method triggered re-render only when the length of threads
-     * changes, hence keep the explicit resortThreads method
-     */
-    this.resortThreads(this.threads);
-  }
-
+  // private, but visible for testing
   handleCommentsDropdownValueChange(e: CustomEvent) {
     const value = e.detail.value;
-    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
-    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
-    else this._handleAllComments();
-  }
-
-  _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
-    if (
-      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
-      !this.hideDropdown
-    ) {
-      // In case of equal timestamps we want futher ordering
-      if (c1.updated && c2.updated && c1.updated !== c2.updated)
-        return c1.updated > c2.updated ? -1 : 1;
+    switch (value) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      default:
+        this.handleAllComments();
     }
-
-    if (c1.thread.path !== c2.thread.path) {
-      // '/PATCHSET' will not come before '/COMMIT' when sorting
-      // alphabetically so move it to the front explicitly
-      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return -1;
-      }
-      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return 1;
-      }
-      return c1.thread.path.localeCompare(c2.thread.path);
-    }
-
-    // Patchset comments have no line/range associated with them
-    if (c1.thread.line !== c2.thread.line) {
-      if (!c1.thread.line || !c2.thread.line) {
-        // one of them is a file level comment, show first
-        return c1.thread.line ? 1 : -1;
-      }
-      return c1.thread.line < c2.thread.line ? -1 : 1;
-    }
-
-    if (c1.thread.patchNum !== c2.thread.patchNum) {
-      if (!c1.thread.patchNum) return 1;
-      if (!c2.thread.patchNum) return -1;
-      // Threads left on Base when comparing Base vs X have patchNum = X
-      // and CommentSide = PARENT
-      // Threads left on 'edit' have patchNum set as latestPatchNum
-      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
-    }
-
-    if (c2.unresolved !== c1.unresolved) {
-      if (!c1.unresolved) return 1;
-      if (!c2.unresolved) return -1;
-    }
-
-    if (c2.hasDraft !== c1.hasDraft) {
-      if (!c1.hasDraft) return 1;
-      if (!c2.hasDraft) return -1;
-    }
-
-    if (c2.updated !== c1.updated) {
-      if (!c1.updated) return 1;
-      if (!c2.updated) return -1;
-      return c2.updated.getTime() - c1.updated.getTime();
-    }
-
-    if (c2.thread.rootId !== c1.thread.rootId) {
-      if (!c1.thread.rootId) return 1;
-      if (!c2.thread.rootId) return -1;
-      return c1.thread.rootId.localeCompare(c2.thread.rootId);
-    }
-
-    return 0;
-  }
-
-  resortThreads(threads: CommentThread[]) {
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
   }
 
   /**
-   * Observer on threads and update _sortedThreads when needed.
-   * Order as follows:
-   * - Patchset level threads (descending based on patchset number)
-   * - unresolved
-   * - comments with drafts
-   * - comments without drafts
-   * - resolved
-   * - comments with drafts
-   * - comments without drafts
-   * - File name
-   * - Line number
-   * - Unresolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   * - Resolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   *
-   * @param threads
-   * @param spliceRecord
+   * Returns all threads that the list may show.
    */
-  @observe('threads', 'threads.splices')
-  _updateSortedThreads(
-    threads: CommentThread[],
-    _: PolymerSpliceChange<CommentThread[]>
-  ) {
-    if (!threads || threads.length === 0) {
-      this._sortedThreads = [];
-      this._displayedThreads = [];
-      return;
-    }
-    // We only want to sort on thread additions / removals to avoid
-    // re-rendering on modifications (add new reply / edit draft etc.).
-    // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
-    // TODO(TS): We have removed a buggy check of the splices here. A splice
-    // with addedCount > 0 or removed.length > 0 should also cause re-sorting
-    // and re-rendering, but apparently spliceRecord is always undefined for
-    // whatever reason.
-    // If there is an unsaved draftThread which is supposed to be replaced with
-    // a saved draftThread then resort all threads
-    const unsavedThread = this._sortedThreads.some(thread =>
-      thread.rootId?.includes('draft__')
-    );
-    if (this._sortedThreads.length === threads.length && !unsavedThread) {
-      // Instead of replacing the _sortedThreads which will trigger a re-render,
-      // we override all threads inside of it.
-      for (const thread of threads) {
-        const idxInSortedThreads = this._sortedThreads.findIndex(
-          t => t.rootId === thread.rootId
-        );
-        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
-      }
-      return;
-    }
-
-    this.resortThreads(threads);
-  }
-
-  _computeDisplayedThreads(
-    sortedThreadsRecord?: PolymerDeepPropertyChange<
-      CommentThread[],
-      CommentThread[]
-    >,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
-    return sortedThreadsRecord.base.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  // private, but visible for testing
+  getAllThreads() {
+    return this.threads.filter(
+      t =>
+        !this.onlyShowRobotCommentsWithHumanReply ||
+        !isRobotThread(t) ||
+        hasHumanReply(t)
     );
   }
 
-  _isFirstThreadWithFileName(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return index === 0 || threads[index - 1].path !== threads[index].path;
+  /**
+   * Returns all threads that are currently shown in the list, respecting the
+   * currently selected filter.
+   */
+  // private, but visible for testing
+  getDisplayedThreads() {
+    const byTimestamp =
+      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+      !this.hideDropdown;
+    return this.getAllThreads()
+      .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+      .filter(t => this.shouldShowThread(t));
   }
 
-  _shouldRenderSeparator(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return (
-      index > 0 &&
-      this._isFirstThreadWithFileName(
-        displayedThreads,
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  private isASelectedAuthor(account?: AccountInfo) {
+    if (!account) return false;
+    return this.selectedAuthors.some(
+      author => account._account_id === author._account_id
     );
   }
 
-  _shouldShowThread(
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (
-      [
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors,
-      ].includes(undefined)
-    ) {
-      return false;
+  private shouldShowThread(thread: CommentThread) {
+    // Never make a thread disappear while the user is editing it.
+    assertIsDefined(thread.rootId, 'thread.rootId');
+    const el = this.queryThreadElement(thread.rootId);
+    if (el?.editing) return true;
+
+    if (this.selectedAuthors.length > 0) {
+      const hasACommentFromASelectedAuthor = thread.comments.some(c =>
+        this.isASelectedAuthor(c.author)
+      );
+      if (!hasACommentFromASelectedAuthor) return false;
     }
 
-    if (selectedAuthors!.length) {
-      if (
-        !thread.comments.some(
-          c =>
-            c.author &&
-            selectedAuthors!.some(
-              author => c.author!._account_id === author._account_id
-            )
-        )
-      ) {
-        return false;
-      }
+    // This is probably redundant, because getAllThreads() filters this out.
+    if (this.onlyShowRobotCommentsWithHumanReply) {
+      if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
     }
 
-    if (
-      !draftsOnly &&
-      !unresolvedOnly &&
-      !onlyShowRobotCommentsWithHumanReply
-    ) {
-      return true;
-    }
+    if (this.draftsOnly && !isDraftThread(thread)) return false;
+    if (this.unresolvedOnly && !isUnresolved(thread)) return false;
 
-    const threadInfo = this._getThreadWithStatusInfo(thread);
-
-    if (threadInfo.isEditing) {
-      return true;
-    }
-
-    if (
-      threadInfo.hasRobotComment &&
-      onlyShowRobotCommentsWithHumanReply &&
-      !threadInfo.hasHumanReplyToRobotComment
-    ) {
-      return false;
-    }
-
-    let filtersCheck = true;
-    if (draftsOnly && unresolvedOnly) {
-      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
-    } else if (draftsOnly) {
-      filtersCheck = threadInfo.hasDraft;
-    } else if (unresolvedOnly) {
-      filtersCheck = threadInfo.unresolved;
-    }
-
-    return filtersCheck;
+    return true;
   }
 
-  _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
-    const comments = thread.comments;
-    const lastComment = comments.length
-      ? comments[comments.length - 1]
-      : undefined;
-    const hasRobotComment = isRobotThread(thread);
-    const hasHumanReplyToRobotComment =
-      hasRobotComment && hasHumanReply(thread);
-    let updated = undefined;
-    if (lastComment) {
-      if (isDraft(lastComment)) updated = lastComment.__date;
-      if (lastComment.updated) updated = parseDate(lastComment.updated);
-    }
-
-    return {
-      thread,
-      hasRobotComment,
-      hasHumanReplyToRobotComment,
-      unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: isDraft(lastComment) && !!lastComment.__editing,
-      hasDraft: !!lastComment && isDraft(lastComment),
-      updated,
-    };
-  }
-
-  _isOnParent(side?: CommentSide) {
-    // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
-    // classified as parent??
-    return !!side;
-  }
-
-  _handleOnlyUnresolved() {
+  private handleOnlyUnresolved() {
     this.unresolvedOnly = true;
-    this._draftsOnly = false;
+    this.draftsOnly = false;
   }
 
-  _handleOnlyDrafts() {
-    this._draftsOnly = true;
+  private handleOnlyDrafts() {
+    this.draftsOnly = true;
     this.unresolvedOnly = false;
   }
 
-  _handleAllComments() {
-    this._draftsOnly = false;
+  private handleAllComments() {
+    this.draftsOnly = false;
     this.unresolvedOnly = false;
   }
 
-  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
-    return !draftsOnly && !unresolvedOnly;
-  }
-
-  _countUnresolved(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
-        .length ?? 0
-    );
-  }
-
-  _countAllThreads(threads?: CommentThread[]) {
-    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
-  }
-
-  _countDrafts(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
-        .length ?? 0
-    );
-  }
-
-  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
-    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
-  }
-
-  _commentTabStateChange(
-    newValue?: CommentTabState,
-    oldValue?: CommentTabState
-  ) {
-    if (!newValue || newValue === oldValue) return;
-    let focusTo: string | undefined;
-    switch (newValue) {
-      case CommentTabState.UNRESOLVED:
-        this._handleOnlyUnresolved();
-        // input is null because it's not rendered yet.
-        focusTo = '#unresolvedRadio';
-        break;
-      case CommentTabState.DRAFTS:
-        this._handleOnlyDrafts();
-        focusTo = '#draftsRadio';
-        break;
-      case CommentTabState.SHOW_ALL:
-        this._handleAllComments();
-        focusTo = '#allRadio';
-        break;
-      default:
-        assertNever(newValue, 'Unsupported preferred state');
-    }
-    const selector = focusTo;
-    window.setTimeout(() => {
-      const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
-      input?.focus();
-    }, 0);
+  private queryThreadElement(rootId: string): GrCommentThread | undefined {
+    const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+    return els.find(el => el.rootId === rootId);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #threads {
-      display: block;
-    }
-    gr-comment-thread {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: left;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
-    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
-    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
-      display: block;
-    }
-    .thread-separator {
-      border-top: 1px solid var(--border-color);
-      margin-top: var(--spacing-xl);
-    }
-    .show-resolved-comments {
-      box-shadow: none;
-      padding-left: var(--spacing-m);
-    }
-    .partypopper{
-      margin-right: var(--spacing-s);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--primary-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    .filter-text, .sort-text, .author-text {
-      margin-right: var(--spacing-s);
-      color: var(--deemphasized-text-color);
-    }
-    .author-text {
-      margin-left: var(--spacing-m);
-    }
-    gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      user-select: none;
-      --label-border-radius: 8px;
-      margin: 0 var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-m);
-      line-height: var(--line-height-normal);
-      cursor: pointer;
-    }
-    gr-account-label:focus {
-      outline: none;
-    }
-    gr-account-label:hover,
-    gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-  </style>
-  <template is="dom-if" if="[[!hideDropdown]]">
-    <div class="header">
-      <span class="sort-text">Sort By:</span>
-      <gr-dropdown-list
-        id="sortDropdown"
-        value="[[sortDropdownValue]]"
-        on-value-change="handleSortDropdownValueChange"
-        items="[[getSortDropdownEntires()]]"
-      >
-      </gr-dropdown-list>
-      <span class="separator"></span>
-      <span class="filter-text">Filter By:</span>
-      <gr-dropdown-list
-        id="filterDropdown"
-        value="[[commentsDropdownValue]]"
-        on-value-change="handleCommentsDropdownValueChange"
-        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
-      >
-      </gr-dropdown-list>
-      <template is="dom-if" if="[[_displayedThreads.length]]">
-        <span class="author-text">From:</span>
-        <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
-          <gr-account-label
-            account="[[item]]"
-            on-click="handleAccountClicked"
-            selectionChipStyle
-            selected="[[isSelected(item, selectedAuthors)]]"
-          > </gr-account-label>
-        </template>
-      </template>
-    </div>
-  </template>
-  <div id="threads" part="threads">
-    <template
-      is="dom-if"
-      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
-    >
-      <div>
-        <span>
-          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
-            <span class="partypopper">\&#x1F389</span>
-          </template>
-          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
-          unresolvedOnly)]]
-          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
-            <gr-button
-              class="show-resolved-comments"
-              link
-              on-click="_handleResolvedCommentsMessageClick">
-                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
-            </gr-button>
-          </template>
-        </span>
-      </div>
-    </template>
-    <template
-      is="dom-repeat"
-      items="[[_displayedThreads]]"
-      as="thread"
-      initial-count="10"
-      target-framerate="60"
-    >
-      <template
-        is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-      >
-        <div class="thread-separator"></div>
-      </template>
-      <gr-comment-thread
-        show-file-path=""
-        show-ported-comment="[[thread.ported]]"
-        show-comment-context="[[showCommentContext]]"
-        change-num="[[changeNum]]"
-        comments="[[thread.comments]]"
-        diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-        project-name="[[change.project]]"
-        is-on-parent="[[_isOnParent(thread.commentSide)]]"
-        line-num="[[thread.line]]"
-        patch-num="[[thread.patchNum]]"
-        path="[[thread.path]]"
-        root-id="{{thread.rootId}}"
-        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-      ></gr-comment-thread>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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 './gr-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {CommentTabState} from '../../../types/events.js';
-import {__testOnly_SortDropdownState} from './gr-thread-list.js';
-import {queryAll} from '../../../test/test-utils.js';
-import {accountOrGroupKey} from '../../../utils/account-util.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
-  let element;
-
-  function getVisibleThreads() {
-    return [...dom(element.root)
-        .querySelectorAll('gr-comment-thread')]
-        .filter(e => e.style.display !== 'none');
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.change = {
-      project: 'testRepo',
-    };
-    element.threads = [
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000001,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '1',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '1',
-            message: 'draft',
-            unresolved: true,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        updated: '1',
-      },
-      {
-        comments: [
-          {
-            path: 'test.txt',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        updated: '2',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            updated: '3',
-            message: 'Another unresolved comment',
-            unresolved: false,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        rootId: '8caddf38_44770ec1',
-        updated: '3',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000003,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '4',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        updated: '4',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '5',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff69',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        updated: '5',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_1',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '6',
-            message: 'patchset comment 1',
-            unresolved: false,
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 2,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_1',
-        updated: '6',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_2',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '7',
-            message: 'patchset comment 2',
-            unresolved: false,
-            __editing: false,
-            patch_set: '3',
-          },
-        ],
-        patchNum: 3,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_2',
-        updated: '7',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '8',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        updated: '8',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 7,
-            updated: '9',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '10',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 7,
-        rootId: 'rc2',
-        updated: '10',
-      },
-    ];
-
-    // use flush to render all (bypass initial-count set on dom-repeat)
-    await flush();
-  });
-
-  test('draft dropdown item only appears when logged in', () => {
-    element.loggedIn = false;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 2);
-    element.loggedIn = true;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 3);
-  });
-
-  test('show all threads by default', () => {
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, element.threads.length);
-    assert.equal(getVisibleThreads().length, element.threads.length);
-  });
-
-  test('show unresolved threads if unresolvedOnly is set', async () => {
-    element.unresolvedOnly = true;
-    await flush();
-    const unresolvedThreads = element.threads.filter(t => t.comments.some(
-        c => c.unresolved
-    ));
-    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
-  });
-
-  test('showing file name takes visible threads into account', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    true);
-    element.unresolvedOnly = true;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    false);
-  });
-
-  test('onlyShowRobotCommentsWithHumanReply ', () => {
-    element.onlyShowRobotCommentsWithHumanReply = true;
-    flush();
-    assert.equal(
-        getVisibleThreads().length,
-        element.threads.length - 1);
-    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
-  });
-
-  suite('_compareThreads', () => {
-    setup(() => {
-      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    });
-
-    test('patchset comes before any other file', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
-      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
-
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      // assigning values to properties such that t2 should come first
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file path is compared lexicographically', () => {
-      const t1 = {thread: {path: 'a.txt'}};
-      const t2 = {thread: {path: 'b.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('patchset comments sorted by reverse patchset', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('patchset comments with same patchset picks unresolved first', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: true};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: false};
-      t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file level comment before line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments sorted by line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt', line: 3}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('comments on same line sorted by reverse patchset', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
-      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments on same line & patchset sorted by unresolved first',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: false};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-
-          t2.hasDraft = true;
-          t1.hasDraft = false;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-        });
-
-    test('comments on same line & patchset & unresolved sorted by draft',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: false};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: true};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), 1);
-          assert.equal(element._compareThreads(t2, t1), -1);
-        });
-  });
-
-  test('_computeSortedThreads', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'patchset_level_2', // Posted on Patchset 3
-      'patchset_level_1', // Posted on Patchset 2
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('_computeSortedThreads with timestamp', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
-    element.resortThreads(element.threads);
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'rc2',
-      'rc1',
-      'patchset_level_2',
-      'patchset_level_1',
-      'zcf0b9fa_fe1a5f62',
-      'scaddf38_44770ec1',
-      '8caddf38_44770ec1',
-      '09a9fb0a_1484e6cf',
-      'ecf0b9fa_fe1a5f62',
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('tapping single author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-    const authors = chips.map(
-        chip => accountOrGroupKey(chip.account))
-        .sort();
-    assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-
-    // accountId 1000001
-    const chip = chips.find(chip => chip.account._account_id === 1000001);
-
-    tap(chip);
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 1);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000001);
-
-    tap(chip); // tapping again resets
-    flush();
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-  });
-
-  test('tapping multiple author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-
-    tap(chips.find(chip => chip.account._account_id === 1000001));
-    tap(chips.find(chip => chip.account._account_id === 1000002));
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 3);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
-        1000001);
-  });
-
-  test('thread removal and sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const index = element.threads.findIndex(t => t.rootId === 'rc2');
-    element.threads.splice(index, 1);
-    element.threads = [...element.threads]; // trigger observers
-    flush();
-    assert.equal(element._sortedThreads.length, 8);
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('modification on thread shold not trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const currentSortedThreads = [...element._sortedThreads];
-    for (const thread of currentSortedThreads) {
-      thread.comments = [...thread.comments];
-    }
-    const modifiedThreads = [...element.threads];
-    modifiedThreads[5] = {...modifiedThreads[5]};
-    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
-      ...modifiedThreads[5].comments[0],
-      unresolved: false,
-    }];
-    element.threads = modifiedThreads;
-    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('reset sortedThreads when threads set to undefiend', () => {
-    element.threads = undefined;
-    assert.deepEqual(element._sortedThreads, []);
-  });
-
-  test('non-equal length of sortThreads and threads' +
-    ' should trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const modifiedThreads = [...element.threads];
-    const currentSortedThreads = [...element._sortedThreads];
-    element._sortedThreads = [];
-    element.threads = modifiedThreads;
-    assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('show all comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.SHOW_ALL}});
-    flush();
-    assert.equal(getVisibleThreads().length, 9);
-  });
-
-  test('unresolved shows all unresolved comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.UNRESOLVED}});
-    flush();
-    assert.equal(getVisibleThreads().length, 4);
-  });
-
-  test('toggle drafts only shows threads with draft comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.DRAFTS}});
-    flush();
-    assert.equal(getVisibleThreads().length, 2);
-  });
-
-  suite('hideDropdown', () => {
-    setup(async () => {
-      element.hideDropdown = true;
-      await flush();
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  suite('empty thread', () => {
-    setup(async () => {
-      element.threads = [];
-      await flush();
-    });
-
-    test('default empty message should show', () => {
-      assert.isTrue(
-          element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..f6b9a81
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,516 @@
+/**
+ * @license
+ * Copyright (C) 2018 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';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+  compareThreads,
+  GrThreadList,
+  __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll} from '../../../test/test-utils';
+import {accountOrGroupKey} from '../../../utils/account-util';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+  createThread,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  NumericChangeId,
+  PatchSetNum,
+  Timestamp,
+} from '../../../api/rest-api';
+import {RobotId, UrlEncodedCommentId} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+  let element: GrThreadList;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.changeNum = 123 as NumericChangeId;
+    element.change = createParsedChange();
+    element.account = createAccountDetailWithId();
+    element.threads = [
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000001 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+            message: 'draft',
+            unresolved: true,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: 'test.txt',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 3 as PatchSetNum,
+            id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+            updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+            updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000003 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+            line: 4,
+            updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_1' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 1',
+            unresolved: false,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 2',
+            unresolved: false,
+            patch_set: '3' as PatchSetNum,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1' as RobotId,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc2' as UrlEncodedCommentId,
+            line: 7,
+            updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2' as RobotId,
+          },
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'c2_1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+    ];
+    await element.updateComplete;
+  });
+
+  suite('sort threads', () => {
+    test('sort all threads', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+        'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+        '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+        'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+        'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+        'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+
+    test('sort all threads by timestamp', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'rc2' as UrlEncodedCommentId,
+        'rc1' as UrlEncodedCommentId,
+        'patchset_level_2' as UrlEncodedCommentId,
+        'patchset_level_1' as UrlEncodedCommentId,
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        'scaddf38_44770ec1' as UrlEncodedCommentId,
+        '8caddf38_44770ec1' as UrlEncodedCommentId,
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+        <span class="author-text">From:</span>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+      </div>
+      <div id="threads" part="threads">
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+      </div>
+    `);
+  });
+
+  test('renders empty', async () => {
+    element.threads = [];
+    await element.updateComplete;
+    expect(queryAndAssert(element, 'div#threads')).dom.to.equal(`
+      <div id="threads" part="threads">
+        <div><span>No comments</span></div>
+      </div>
+    `);
+  });
+
+  test('tapping single author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+    assert.deepEqual(authors, [
+      1 as AccountId,
+      1000000 as AccountId,
+      1000001 as AccountId,
+      1000002 as AccountId,
+      1000003 as AccountId,
+    ]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1000001);
+    tap(chip!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+
+    tap(chip!);
+    await element.updateComplete;
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('tapping multiple author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+
+    tap(chips.find(chip => chip.account?._account_id === 1000001)!);
+    tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 3);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[1].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[2].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+  });
+
+  test('show all comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.SHOW_ALL},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('unresolved shows all unresolved comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.UNRESOLVED},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.DRAFTS},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 2);
+  });
+
+  suite('hideDropdown', () => {
+    test('header hidden for hideDropdown=true', async () => {
+      element.hideDropdown = true;
+      await element.updateComplete;
+      assert.isUndefined(query(element, '.header'));
+    });
+
+    test('header shown for hideDropdown=false', async () => {
+      element.hideDropdown = false;
+      await element.updateComplete;
+      assert.isDefined(query(element, '.header'));
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(async () => {
+      element.threads = [];
+      await element.updateComplete;
+    });
+
+    test('default empty message should show', () => {
+      const threadsEl = queryAndAssert(element, '#threads');
+      assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+    });
+  });
+});
+
+suite('compareThreads', () => {
+  let t1: CommentThread;
+  let t2: CommentThread;
+
+  const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+    compareThreads(thread1, thread2);
+
+  const checkOrder = (expected: CommentThread[]) => {
+    assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+    assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+  };
+
+  setup(() => {
+    t1 = createThread({});
+    t2 = createThread({});
+  });
+
+  test('patchset-level before file comments', () => {
+    t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+    t2.path = SpecialFilePath.COMMIT_MESSAGE;
+    checkOrder([t1, t2]);
+  });
+
+  test('paths lexicographically', () => {
+    t1.path = 'a.txt';
+    t2.path = 'b.txt';
+    checkOrder([t1, t2]);
+  });
+
+  test('patchsets in reverse order', () => {
+    t1.patchNum = 2 as PatchSetNum;
+    t2.patchNum = 3 as PatchSetNum;
+    checkOrder([t2, t1]);
+  });
+
+  test('file level comment before line', () => {
+    t1.line = 123;
+    t2.line = 'FILE';
+    checkOrder([t2, t1]);
+  });
+
+  test('comments sorted by line', () => {
+    t1.line = 123;
+    t2.line = 321;
+    checkOrder([t1, t2]);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index b213fa6..74d0e30 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -28,7 +28,7 @@
   @property({type: Object})
   eventTarget: HTMLElement | null = null;
 
-  private checksService = getAppContext().checksService;
+  private checksModel = getAppContext().checksModel;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -80,7 +80,7 @@
 
   handleClick(e: Event) {
     e.stopPropagation();
-    this.checksService.triggerAction(this.action);
+    this.checksModel.triggerAction(this.action);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 342f54b..de2099e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -32,14 +32,7 @@
   Tag,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
-import {
-  CheckRun,
-  checksSelectedPatchsetNumber$,
-  RunResult,
-  someProvidersAreLoadingSelected$,
-  topLevelActionsSelected$,
-  topLevelLinksSelected$,
-} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../services/checks/checks-model';
 import {
   allResults,
   firstPrimaryLink,
@@ -62,9 +55,7 @@
   LabelNameToInfoMap,
   PatchSetNumber,
 } from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
 import {getAppContext} from '../../services/app-context';
-import {repoConfig$} from '../../services/config/config-model';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
@@ -96,11 +87,13 @@
   @state()
   labels?: LabelNameToInfoMap;
 
-  private checksService = getAppContext().checksService;
+  private changeModel = getAppContext().changeModel;
+
+  private checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, labels$, x => (this.labels = x));
+    subscribe(this, this.changeModel.labels$, x => (this.labels = x));
   }
 
   static override get styles() {
@@ -494,7 +487,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.checksModel.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -538,7 +531,9 @@
   @state()
   repoConfig?: ConfigInfo;
 
-  private changeService = getAppContext().changeService;
+  private changeModel = getAppContext().changeModel;
+
+  private configModel = getAppContext().configModel;
 
   static override get styles() {
     return [
@@ -563,7 +558,7 @@
 
   constructor() {
     super();
-    subscribe(this, repoConfig$, x => (this.repoConfig = x));
+    subscribe(this, this.configModel.repoConfig$, x => (this.repoConfig = x));
   }
 
   override render() {
@@ -624,7 +619,7 @@
       const end = pointer?.range?.end_line;
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
-      const change = this.changeService.getChange();
+      const change = this.changeModel.getChange();
       assertIsDefined(change);
       const path = pointer.path;
       const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -732,21 +727,35 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
-  private readonly checksService = getAppContext().checksService;
+  private readonly changeModel = getAppContext().changeModel;
+
+  private readonly checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
-    subscribe(this, topLevelLinksSelected$, x => (this.links = x));
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      this.checksModel.topLevelActionsSelected$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.topLevelLinksSelected$,
+      x => (this.links = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
     subscribe(
       this,
-      someProvidersAreLoadingSelected$,
+      this.changeModel.latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
   }
@@ -1100,7 +1109,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.checksModel.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -1111,11 +1120,11 @@
   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);
+    this.checksModel.setPatchset(patchset as PatchSetNumber);
   }
 
   private goToLatestPatchset() {
-    this.checksService.setPatchset(undefined);
+    this.checksModel.setPatchset(undefined);
   }
 
   private createPatchsetDropdownItems() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 474d2f2..20041de 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,11 +33,11 @@
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
-  allRunsSelectedPatchset$,
   CheckRun,
   ChecksPatchset,
   ErrorMessages,
-  errorMessagesLatest$,
+} from '../../services/checks/checks-model';
+import {
   fakeActions,
   fakeLinks,
   fakeRun0,
@@ -45,9 +45,7 @@
   fakeRun2,
   fakeRun3,
   fakeRun4Att,
-  loginCallbackLatest$,
-  updateStateSetResults,
-} from '../../services/checks/checks-model';
+} from '../../services/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
 import {
@@ -391,13 +389,25 @@
 
   private flagService = getAppContext().flagsService;
 
-  private checksService = getAppContext().checksService;
+  private checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+    subscribe(
+      this,
+      this.checksModel.allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
   }
 
   static override get styles() {
@@ -619,7 +629,7 @@
           link
           ?disabled=${runButtonDisabled}
           @click="${() => {
-            actions.forEach(action => this.checksService.triggerAction(action));
+            actions.forEach(action => this.checksModel.triggerAction(action));
           }}"
           >Run Selected</gr-button
         >
@@ -659,25 +669,79 @@
   }
 
   none() {
-    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
+    this.checksModel.updateStateSetResults(
+      'f0',
+      [],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f1',
+      [],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f2',
+      [],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f3',
+      [],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f4',
+      [],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
   }
 
   all() {
-    updateStateSetResults(
+    this.checksModel.updateStateSetResults(
       'f0',
       [fakeRun0],
       fakeActions,
       fakeLinks,
       ChecksPatchset.LATEST
     );
-    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
+    this.checksModel.updateStateSetResults(
+      'f1',
+      [fakeRun1],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f2',
+      [fakeRun2],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f3',
+      [fakeRun3],
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    this.checksModel.updateStateSetResults(
+      'f4',
+      fakeRun4Att,
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
   }
 
   toggle(
@@ -687,7 +751,7 @@
     links: Link[] = []
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(
+    this.checksModel.updateStateSetResults(
       plugin,
       newRuns,
       actions,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d1ccd11..a9c30c5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -17,16 +17,9 @@
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {Action} from '../../api/checks';
-import {
-  CheckResult,
-  CheckRun,
-  allResultsSelected$,
-  checksSelectedPatchsetNumber$,
-  allRunsSelectedPatchset$,
-} from '../../services/checks/checks-model';
+import {CheckResult, CheckRun} from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -68,19 +61,33 @@
     number | undefined
   >();
 
-  private readonly checksService = getAppContext().checksService;
+  private readonly changeModel = getAppContext().changeModel;
+
+  private readonly checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, allResultsSelected$, x => (this.results = x));
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      this.checksModel.allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.allResultsSelected$,
+      x => (this.results = x)
+    );
+    subscribe(
+      this,
+      this.checksModel.checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
-    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(
+      this,
+      this.changeModel.latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
       this.handleActionTriggered(e.detail.action, e.detail.run)
@@ -140,7 +147,7 @@
   }
 
   handleActionTriggered(action: Action, run?: CheckRun) {
-    this.checksService.triggerAction(action, run);
+    this.checksModel.triggerAction(action, run);
   }
 
   handleRunSelected(e: RunSelectedEvent) {
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 29c8eca..be36640 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
@@ -36,9 +37,6 @@
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {getAppContext} from '../../../services/app-context';
-import {serverConfig$} from '../../../services/config/config-model';
-import {myTopMenuItems$} from '../../../services/user/user-model';
-import {assertIsDefined} from '../../../utils/common-util';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -158,7 +156,9 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly configModel = getAppContext().configModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -168,21 +168,21 @@
   }
 
   override connectedCallback() {
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
-    // all. We need the service to manage the model, but we are not making any
-    // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(this.userService);
-
     super.connectedCallback();
     this._loadAccount();
 
     this.subscriptions.push(
-      myTopMenuItems$.subscribe(items => {
-        this._userLinks = items.map(this._createHeaderLink);
-      })
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.my ?? []),
+          distinctUntilChanged()
+        )
+        .subscribe(items => {
+          this._userLinks = items.map(this._createHeaderLink);
+        })
     );
     this.subscriptions.push(
-      serverConfig$.subscribe(config => {
+      this.configModel.serverConfig$.subscribe(config => {
         if (!config) return;
         this._retrieveFeedbackURL(config);
         this._retrieveRegisterURL(config);
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 027c976..2a35494 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -66,7 +66,7 @@
   AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView, updateState} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
@@ -311,6 +311,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly routerModel = getAppContext().routerModel;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly flagsService = getAppContext().flagsService;
@@ -323,11 +325,11 @@
   }
 
   _setParams(params: AppElementParams | GenerateUrlParameters) {
-    updateState(
-      params.view,
-      'changeNum' in params ? params.changeNum : undefined,
-      'patchNum' in params ? params.patchNum ?? undefined : undefined
-    );
+    this.routerModel.updateState({
+      view: params.view,
+      changeNum: 'changeNum' in params ? params.changeNum : undefined,
+      patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
+    });
     this._appElement().params = params;
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index e82ea89..9392cb9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -24,6 +24,7 @@
   BasePatchSetNum,
   EditPatchSetNum,
   PatchSetNum,
+  RobotCommentInfo,
   RobotId,
   RobotRunId,
   Timestamp,
@@ -37,7 +38,6 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {UIRobot} from '../../../utils/comment-util';
 import {
   CloseFixPreviewEventDetail,
   EventType,
@@ -50,7 +50,7 @@
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
 
-  const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+  const ROBOT_COMMENT_WITH_TWO_FIXES: RobotCommentInfo = {
     id: '1' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
@@ -62,7 +62,7 @@
     ],
   };
 
-  const ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+  const ROBOT_COMMENT_WITH_ONE_FIX: RobotCommentInfo = {
     id: '2' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
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 32c732e..50399be 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
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import {
-  CommentBasics,
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
@@ -31,7 +30,6 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  UIComment,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
@@ -41,7 +39,7 @@
   addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
@@ -114,7 +112,7 @@
    * patchNum and basePatchNum properties to represent the range.
    */
   getPaths(patchRange?: PatchRange): CommentMap {
-    const responses: {[path: string]: UIComment[]}[] = [
+    const responses: {[path: string]: Comment[]}[] = [
       this._comments,
       this.drafts,
       this._robotComments,
@@ -139,25 +137,11 @@
   }
 
   /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   */
-  getCommentsForThread(rootId: UrlEncodedCommentId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // and the diff view is updating comments, there will no longer be a thread
-    // found.  In this case, return null.
-    return threadMatch ? threadMatch.comments : null;
-  }
-
-  /**
    * Gets all the comments and robot comments for the given change.
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentBasics[]} = {};
+    const publishedComments: {[path: string]: CommentInfo[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -191,8 +175,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): Comment[] {
-    const comments: Comment[] = this._comments[path] || [];
+  ): CommentInfo[] {
+    const comments: CommentInfo[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -228,43 +212,18 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      drafts,
-      this._portedComments,
-      this._portedDrafts
-    );
-  }
-
-  cloneWithUpdatedPortedComments(
-    portedComments?: PathToCommentsInfoMap,
-    portedDrafts?: PathToCommentsInfoMap
-  ) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      this._drafts,
-      portedComments,
-      portedDrafts
-    );
-  }
-
   /**
    * Get the drafts for a path and optional patch num.
    *
    * This will return a shallow copy of all drafts every time,
    * so changes on any copy will not affect other copies.
    */
-  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
-    let comments = this._drafts[path] || [];
+  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+    let drafts = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => c.patch_set === patchNum);
+      drafts = drafts.filter(c => c.patch_set === patchNum);
     }
-    return comments.map(c => {
-      return {...c, __draft: true};
-    });
+    return drafts;
   }
 
   /**
@@ -272,7 +231,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): Comment[] {
+  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -292,8 +251,8 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
-    let comments: Comment[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+    let comments: CommentInfo[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -306,17 +265,13 @@
       robotComments = this._robotComments[path];
     }
 
-    drafts.forEach(d => {
-      d.__draft = true;
-    });
-
-    return comments
-      .concat(drafts)
-      .concat(robotComments)
+    const all = comments.concat(drafts).concat(robotComments);
+    const final = all
       .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+    return final;
   }
 
   /**
@@ -367,7 +322,7 @@
     // ported comments will involve comments that may not belong to the
     // current patchrange, so we need to form threads for them using all
     // comments
-    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+    const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
@@ -398,7 +353,6 @@
         return false;
       }
 
-      thread.diffSide = Side.RIGHT;
       if (thread.commentSide === CommentSide.PARENT) {
         // TODO(dhruvsri): Add handling for merge parents
         if (
@@ -406,7 +360,6 @@
           !!thread.mergeParentNum
         )
           return false;
-        thread.diffSide = Side.LEFT;
       }
 
       if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
@@ -423,8 +376,7 @@
     patchRange: PatchRange
   ): CommentThread[] {
     const threads = createCommentThreads(
-      this.getCommentsForFile(file, patchRange),
-      patchRange
+      this.getCommentsForFile(file, patchRange)
     );
     threads.push(...this._getPortedCommentThreads(file, patchRange));
     return threads;
@@ -442,7 +394,10 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+  getCommentsForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentInfo[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -464,11 +419,11 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
+    let comments: CommentInfo[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray(
+      comments = this._commentObjToArray<CommentInfo>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
@@ -579,8 +534,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
-    let drafts: Comment[] = [];
+    let comments: CommentInfo[] = [];
+    let drafts: CommentInfo[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 7e01371..9770261 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,7 +20,7 @@
 import {ChangeComments} from './gr-comment-api.js';
 import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
 import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide, Side} from '../../../constants/constants.js';
+import {CommentSide} from '../../../constants/constants.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
@@ -207,7 +207,6 @@
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
         assert.equal(portedThreads.length, 1);
         assert.equal(portedThreads[0].line, 31);
-        assert.equal(portedThreads[0].diffSide, Side.LEFT);
 
         assert.equal(changeComments._getPortedCommentThreads(
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
@@ -363,6 +362,7 @@
           ...createComment(),
           id: '01',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 1,
           updated: makeTime(1),
@@ -379,6 +379,7 @@
           id: '02',
           in_reply_to: '04',
           patch_set: 2,
+          path: 'file/one',
           unresolved: true,
           line: 1,
           updated: makeTime(3),
@@ -388,6 +389,7 @@
           ...createComment(),
           id: '03',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 2,
           updated: makeTime(1),
@@ -397,6 +399,7 @@
           ...createComment(),
           id: '04',
           patch_set: 2,
+          path: 'file/one',
           line: 1,
           updated: makeTime(1),
         };
@@ -470,6 +473,7 @@
           side: PARENT,
           line: 1,
           updated: makeTime(3),
+          path: 'file/one',
         };
 
         commentObjs['13'] = {
@@ -481,6 +485,7 @@
           // Draft gets lower timestamp than published comment, because we
           // want to test that the draft still gets sorted to the end.
           updated: makeTime(2),
+          path: 'file/one',
         };
 
         commentObjs['14'] = {
@@ -597,10 +602,6 @@
         const path = 'file/one';
         const drafts = element._changeComments.getAllDraftsForPath(path);
         assert.equal(drafts.length, 2);
-        const aCopyOfDrafts = element._changeComments
-            .getAllDraftsForPath(path);
-        assert.deepEqual(drafts, aCopyOfDrafts);
-        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
       });
 
       test('computeUnresolvedNum', () => {
@@ -828,24 +829,6 @@
         const threads = element._changeComments.getAllThreadsForChange();
         assert.deepEqual(threads, expectedThreads);
       });
-
-      test('getCommentsForThreadGroup', () => {
-        let expectedComments = [
-          {...commentObjs['04'], path: 'file/one'},
-          {...commentObjs['02'], path: 'file/one'},
-          {...commentObjs['13'], path: 'file/one'},
-        ];
-        assert.deepEqual(element._changeComments.getCommentsForThread('04'),
-            expectedComments);
-
-        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
-            null);
-      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index 54b2450f..6b1294a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -39,7 +39,7 @@
 /** CSS class for the currently hovered token. */
 const CSS_HIGHLIGHT = 'token-highlight';
 
-export const HOVER_DELAY_MS = 200;
+export const HOVER_DELAY_MS = 500;
 
 const LINE_LENGTH_LIMIT = 500;
 
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 f8fb40c..25a1a00 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
@@ -25,9 +25,7 @@
 import {
   anyLineTooLong,
   getLine,
-  getRange,
   getSide,
-  rangesEqual,
   SYNTAX_MAX_LINE_LENGTH,
 } from '../gr-diff/gr-diff-utils';
 import {getAppContext} from '../../../services/app-context';
@@ -37,7 +35,11 @@
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
@@ -83,7 +85,6 @@
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {DisplayLine, RenderPreferences} from '../../../api/diff';
@@ -266,6 +267,8 @@
 
   private readonly browserModel = getAppContext().browserModel;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
@@ -288,7 +291,7 @@
       // change in some way, and that we should update any models we may want
       // to keep in sync.
       'create-comment',
-      e => this._handleCreateComment(e)
+      e => this._handleCreateThread(e)
     );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
@@ -318,7 +321,7 @@
       this._loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
       })
     );
@@ -734,30 +737,29 @@
   }
 
   _threadsChanged(threads: CommentThread[]) {
-    const threadEls = new Set<GrCommentThread>();
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
     for (const threadEl of this.getThreadEls()) {
       if (threadEl.rootId) {
         rootIdToThreadEl.set(threadEl.rootId, threadEl);
       }
     }
+    const dontRemove = new Set<GrCommentThread>();
     for (const thread of threads) {
       const existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
       if (existingThreadEl) {
-        this._updateThreadElement(existingThreadEl, thread);
-        threadEls.add(existingThreadEl);
+        existingThreadEl.thread = thread;
+        dontRemove.add(existingThreadEl);
       } else {
         const threadEl = this._createThreadElement(thread);
         this._attachThreadElement(threadEl);
-        threadEls.add(threadEl);
+        dontRemove.add(threadEl);
       }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
-      if (threadEls.has(threadEl)) continue;
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
+      if (dontRemove.has(threadEl)) continue;
+      threadEl.remove();
     }
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
@@ -785,10 +787,10 @@
     );
   }
 
-  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+  _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
     if (!this.patchRange) throw Error('patch range not set');
 
-    const {lineNum, side, range, path} = e.detail;
+    const {lineNum, side, range} = e.detail;
 
     // Usually, the comment is stored on the patchset shown on the side the
     // user added the comment on, and the commentSide will be REVISION.
@@ -806,18 +808,27 @@
         ? CommentSide.PARENT
         : CommentSide.REVISION;
     if (!this.canCommentOnPatchSetNum(patchNum)) return;
-    const threadEl = this._getOrCreateThread({
+    const path =
+      this.file?.basePath &&
+      side === Side.LEFT &&
+      commentSide === CommentSide.REVISION
+        ? this.file?.basePath
+        : this.path;
+    assertIsDefined(path, 'path');
+
+    const newThread: CommentThread = {
+      rootId: undefined,
       comments: [],
-      path,
-      diffSide: side,
-      commentSide,
       patchNum,
+      commentSide,
+      // TODO: Maybe just compute from patchRange.base on the fly?
+      mergeParentNum: this._parentIndex ?? undefined,
+      path,
       line: lineNum,
       range,
-    });
-    threadEl.addOrEditDraft(lineNum, range);
-
-    this.reporting.recordDraftInteraction();
+    };
+    const el = this._createThreadElement(newThread);
+    this._attachThreadElement(el);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
@@ -846,21 +857,6 @@
     return true;
   }
 
-  /**
-   * Gets or creates a comment thread at a given location.
-   * May provide a range, to get/create a range comment.
-   */
-  _getOrCreateThread(thread: CommentThread): GrCommentThread {
-    let threadEl = this._getThreadEl(thread);
-    if (!threadEl) {
-      threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
-    } else {
-      this._updateThreadElement(threadEl, thread);
-    }
-    return threadEl;
-  }
-
   _attachThreadElement(threadEl: Element) {
     this.$.diff.appendChild(threadEl);
   }
@@ -873,67 +869,38 @@
   }
 
   _createThreadElement(thread: CommentThread) {
+    assertIsDefined(this.patchRange, 'patchRange');
+    const commentProps = {
+      patch_set: thread.patchNum,
+      side: thread.commentSide,
+      parent: thread.mergeParentNum,
+    };
+    let diffSide: Side;
+    if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.LEFT;
+    } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.RIGHT;
+    } else {
+      const propsStr = JSON.stringify(commentProps);
+      const rangeStr = JSON.stringify(this.patchRange);
+      throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
+    }
+
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute(
-      'slot',
-      `${thread.diffSide}-${thread.line || 'LOST'}`
-    );
-    this._updateThreadElement(threadEl, thread);
-    return threadEl;
-  }
-
-  _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
-    threadEl.comments = thread.comments;
-    threadEl.diffSide = thread.diffSide;
-    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
-    threadEl.parentIndex = this._parentIndex;
-    // Use path before renmaing when comment added on the left when comparing
-    // two patch sets (not against base)
-    if (
-      this.file &&
-      this.file.basePath &&
-      thread.diffSide === Side.LEFT &&
-      !threadEl.isOnParent
-    ) {
-      threadEl.path = this.file.basePath;
-    } else {
-      threadEl.path = this.path;
-    }
-    threadEl.changeNum = this.changeNum;
-    threadEl.patchNum = thread.patchNum;
+    threadEl.rootId = thread.rootId;
+    threadEl.thread = thread;
     threadEl.showPatchset = false;
     threadEl.showPortedComment = !!thread.ported;
-    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
-    // GrCommentThread does not understand 'FILE', but requires undefined.
-    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
-    threadEl.projectName = this.projectName;
-    threadEl.range = thread.range;
-  }
-
-  /**
-   * Gets a comment thread element at a given location.
-   * May provide a range, to get a range comment.
-   */
-  _getThreadEl(thread: CommentThread): GrCommentThread | null {
-    let line: LineInfo;
-    if (thread.diffSide === Side.LEFT) {
-      line = {beforeNumber: thread.line};
-    } else if (thread.diffSide === Side.RIGHT) {
-      line = {afterNumber: thread.line};
-    } else {
-      throw new Error(`Unknown side: ${thread.diffSide}`);
+    // These attributes are the "interface" between comment threads and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+    threadEl.setAttribute('diff-side', `${diffSide}`);
+    threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+    if (thread.range) {
+      threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
     }
-    function matchesRange(threadEl: GrCommentThread) {
-      return rangesEqual(getRange(threadEl), thread.range);
-    }
-
-    const filteredThreadEls = this._filterThreadElsForLocation(
-      this.getThreadEls(),
-      line,
-      thread.diffSide
-    ).filter(matchesRange);
-    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    return threadEl;
   }
 
   _filterThreadElsForLocation(
@@ -1181,8 +1148,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-update': CustomEvent;
-    'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
   }
 }
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 dd15462..6149c82 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
@@ -948,7 +948,6 @@
     });
 
     test('creates comments if they do not exist yet', () => {
-      const diffSide = Side.LEFT;
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 2,
@@ -957,7 +956,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 3,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -966,10 +965,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[0].range, undefined);
-      assert.equal(threads[0].patchNum, 2);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.range, undefined);
+      assert.equal(threads[0].thread.patchNum, 2);
 
       // Try to fetch a thread with a different range.
       const range = {
@@ -986,7 +985,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 1,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
           range,
         },
@@ -996,10 +995,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 2);
-      assert.equal(threads[1].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[1].range, range);
-      assert.equal(threads[1].patchNum, 3);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread.range, range);
+      assert.equal(threads[1].thread.patchNum, 3);
     });
 
     test('should not be on parent if on the right', () => {
@@ -1014,10 +1013,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
     });
 
     test('should be on parent if right and base is PARENT', () => {
@@ -1032,10 +1032,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should be on parent if right and base negative', () => {
@@ -1050,10 +1051,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should not be on parent otherwise', () => {
@@ -1068,24 +1070,25 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('thread should use old file path if first created ' +
-    'on patch set (left) before renaming', () => {
-      const diffSide = Side.LEFT;
+    'on patch set (left) before renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1094,22 +1097,22 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.basePath);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.basePath);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (right) after renaming', () => {
-      const diffSide = Side.RIGHT;
+    test('thread should use new file path if first created ' +
+    'on patch set (right) after renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.RIGHT,
           path: '/p',
         },
       }));
@@ -1118,23 +1121,27 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
-    test('multiple threads created on the same range', () => {
+    test('multiple threads created on the same range', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
-      const comment = createComment();
-      comment.range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 2,
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3,
       };
       const thread = createCommentThread([comment]);
       element.threads = [thread];
@@ -1159,18 +1166,18 @@
       assert.equal(threads.length, 2);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (left) but is base', () => {
-      const diffSide = Side.LEFT;
+    test('thread should use new file path if first created ' +
+    'on patch set (left) but is base', async () => {
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1179,8 +1186,8 @@
           dom(element.$.diff).queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
     test('cannot create thread on an edit', () => {
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 0d63360..fd30c6a 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
@@ -50,7 +50,7 @@
   // Private but accessed by tests.
   readonly browserModel = getAppContext().browserModel;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -83,7 +83,7 @@
    */
   setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userService.updatePreferences({diff_view: newMode});
+      this.userModel.updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 7f7f265..f469799 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -19,7 +19,6 @@
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {updateDiffPreferences} from '../../../services/user/user-model';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
@@ -37,7 +36,6 @@
       line_wrapping: true,
     };
     element.diffPrefs = originalDiffPrefs;
-    updateDiffPreferences(originalDiffPrefs);
     await flush();
     element.open();
     await flush();
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 c4e2488..ff02d61 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
@@ -31,6 +31,8 @@
 import '../gr-diff-mode-selector/gr-diff-mode-selector';
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
+import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-overlay/gr-overlay';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-view_html';
@@ -82,6 +84,7 @@
   RepoName,
   RevisionInfo,
   RevisionPatchSetNum,
+  ServerInfo,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {
@@ -105,29 +108,17 @@
 import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
 import {EventType, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {isFalse, throttleWrap, until} from '../../../utils/async-util';
-import {
-  changeComments$,
-  commentsLoading$,
-} from '../../../services/comments/comments-model';
 import {filter, take} from 'rxjs/operators';
 import {Subscription, combineLatest} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
-  preferences$,
-  diffPreferences$,
-} from '../../../services/user/user-model';
-import {
-  diffPath$,
-  currentPatchNum$,
-  change$,
-  changeLoading$,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 import {DisplayLine} from '../../../api/diff';
+import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
@@ -154,6 +145,8 @@
     diffPreferencesDialog: GrOverlay;
     applyFixDialog: GrApplyFixDialog;
     modeSelect: GrDiffModeSelector;
+    downloadOverlay: GrOverlay;
+    downloadDialog: GrDownloadDialog;
   };
 }
 
@@ -236,6 +229,9 @@
   _projectConfig?: ConfigInfo;
 
   @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: Object})
   _userPrefs?: PreferencesInfo;
 
   @property({type: Boolean})
@@ -359,16 +355,20 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  // Private but used in tests.
+  readonly routerModel = getAppContext().routerModel;
 
-  private readonly changeService = getAppContext().changeService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
-  // Private but used in tests
+  // Private but used in tests.
+  readonly changeModel = getAppContext().changeModel;
+
+  // Private but used in tests.
   readonly browserModel = getAppContext().browserModel;
 
-  // We just want to make sure that CommentsService is instantiated.
-  // Otherwise subscribing to the model won't emit any data.
-  private readonly _commentsService = getAppContext().commentsService;
+  // Private but used in tests.
+  readonly commentsModel = getAppContext().commentsModel;
 
   private readonly shortcuts = getAppContext().shortcutsService;
 
@@ -380,11 +380,6 @@
 
   private subscriptions: Subscription[] = [];
 
-  constructor() {
-    super();
-    this._commentsService;
-  }
-
   override connectedCallback() {
     super.connectedCallback();
     this._throttledToggleFileReviewed = throttleWrap(_ =>
@@ -393,25 +388,28 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    this.restApiService.getConfig().then(config => {
+      this._serverConfig = config;
+    });
 
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this._changeComments = changeComments;
       })
     );
 
     this.subscriptions.push(
-      preferences$.subscribe(preferences => {
+      this.userModel.preferences$.subscribe(preferences => {
         this._userPrefs = preferences;
       })
     );
     this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this._prefs = diffPreferences;
       })
     );
     this.subscriptions.push(
-      change$.subscribe(change => {
+      this.changeModel.change$.subscribe(change => {
         // The diff view is tied to a specfic change number, so don't update
         // _change to undefined.
         if (change) this._change = change;
@@ -423,7 +421,12 @@
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     this.subscriptions.push(
-      combineLatest(currentPatchNum$, routerView$, diffPath$, diffPreferences$)
+      combineLatest([
+        this.changeModel.currentPatchNum$,
+        this.routerModel.routerView$,
+        this.changeModel.diffPath$,
+        this.userModel.diffPreferences$,
+      ])
         .pipe(
           filter(
             ([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -438,7 +441,9 @@
           this.setReviewedStatus(currentPatchNum!, path!, diffPrefs);
         })
     );
-    this.subscriptions.push(diffPath$.subscribe(path => (this._path = path)));
+    this.subscriptions.push(
+      this.changeModel.diffPath$.subscribe(path => (this._path = path))
+    );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
@@ -516,7 +521,7 @@
 
   _getChangeEdit() {
     assertIsDefined(this._changeNum, '_changeNum');
-    return this.restApiService.getChangeEdit(this._changeNum);
+    return this.restApiService.getChangeEdit(this._changeNum, true);
   }
 
   _getSortedFileList(files?: Files) {
@@ -768,8 +773,16 @@
   }
 
   _handleOpenDownloadDialog() {
-    this.set('changeViewState.showDownloadDialog', true);
-    this._navToChangeView();
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay.setFocusStops(
+        this.$.downloadDialog.getFocusStops()
+      );
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose() {
+    this.$.downloadOverlay.close();
   }
 
   _handleUpToChange() {
@@ -784,9 +797,9 @@
   _handleToggleDiffMode() {
     if (!this._userPrefs) return;
     if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.userModel.updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
@@ -1046,7 +1059,7 @@
         GerritNav.navigateToChange(this._change);
         return;
       }
-      this.changeService.updatePath(comment.path);
+      this.changeModel.updatePath(comment.path);
 
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -1056,7 +1069,7 @@
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
-        this.changeService.updatePath(this.params.path);
+        this.changeModel.updatePath(this.params.path);
       }
       if (this.params.patchNum) {
         this._patchRange = {
@@ -1122,7 +1135,7 @@
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this.changeService.updatePath(undefined);
+    this.changeModel.updatePath(undefined);
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1146,8 +1159,15 @@
     }
 
     const promises: Promise<unknown>[] = [];
-    if (!this._change) promises.push(until(changeLoading$, isFalse));
-    promises.push(until(commentsLoading$, isFalse));
+    if (!this._change) {
+      promises.push(
+        until(
+          this.changeModel.changeLoadingStatus$,
+          status => status === LoadingStatus.LOADED
+        )
+      );
+    }
+    promises.push(until(this.commentsModel.commentsLoading$, isFalse));
     promises.push(
       this._getChangeEdit().then(edit => {
         if (edit) {
@@ -1764,7 +1784,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
+    this.userModel.getDiffPreferences();
   }
 
   _computeCanEdit(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 59e15a7..ef38440 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -425,4 +425,13 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
+  <gr-overlay id="downloadOverlay">
+    <gr-download-dialog
+      id="downloadDialog"
+      change="[[_change]]"
+      patch-num="[[_patchRange.patchNum]]"
+      config="[[_serverConfig.download]]"
+      on-close="_handleDownloadDialogClose"
+    ></gr-download-dialog>
+  </gr-overlay>
 `;
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 2367342..b2d5a27 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
@@ -21,7 +21,7 @@
 import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {GerritView, _testOnly_setState as setRouterModelState} from '../../../services/router/router-model.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
@@ -31,9 +31,6 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {Side} from '../../../api/diff.js';
-import {_testOnly_setState as setUserModelState, _testOnly_getState as getUserModelState} from '../../../services/user/user-model.js';
-import {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
-import {_testOnly_setState as setCommentState} from '../../../services/comments/comments-model.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -94,7 +91,7 @@
       ]});
       await flush();
 
-      setCommentState({
+      element.commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {},
@@ -140,14 +137,15 @@
         sinon.stub(element.reporting, 'diffViewDisplayed');
         sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
         sinon.spy(element, '_paramsChanged');
-        setChangeModelState({change: {
-          ...createChange(),
-          revisions: createRevisions(11),
-        }});
+        element.changeModel.setState({
+          change: {
+            ...createChange(),
+            revisions: createRevisions(11),
+          }});
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
-        setCommentState({
+        element.commentsModel.setState({
           comments: {
             '/COMMIT_MSG': [
               {
@@ -221,7 +219,7 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          setCommentState({
+          element.commentsModel.setState({
             comments: {
               '/COMMIT_MSG': [
                 {
@@ -249,10 +247,11 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          setChangeModelState({change: {
-            ...createChange(),
-            revisions: createRevisions(11),
-          }});
+          element.changeModel.setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -273,7 +272,7 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          setCommentState({
+          element.commentsModel.setState({
             comments: {
               '/COMMIT_MSG': [
                 {
@@ -301,10 +300,11 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          setChangeModelState({change: {
-            ...createChange(),
-            revisions: createRevisions(11),
-          }});
+          element.changeModel.setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -353,7 +353,7 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
-      setCommentState({
+      element.commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             {
@@ -382,10 +382,11 @@
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element._change = undefined;
-      setChangeModelState({change: {
-        ...createChange(),
-        revisions: createRevisions(11),
-      }});
+      element.changeModel.setState({
+        change: {
+          ...createChange(),
+          revisions: createRevisions(11),
+        }});
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -797,9 +798,10 @@
           {patchNum: 10, basePatchNum: 5}),
       'Should navigate to /c/42/5..10');
 
-      assert.isUndefined(element.changeViewState.showDownloadDialog);
+      const downloadOverlayStub = sinon.stub(element.$.downloadOverlay, 'open')
+          .returns(Promise.resolve());
       MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(element.changeViewState.showDownloadDialog);
+      assert.isTrue(downloadOverlayStub.called);
     });
 
     test('keyboard shortcuts with old patch number', () => {
@@ -1199,10 +1201,12 @@
         ...createDefaultDiffPrefs(),
         manual_review: true,
       };
-      setUserModelState({...getUserModelState(), diffPreferences});
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.userModel.setDiffPreferences(diffPreferences);
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
-      setRouterModelState({
+      element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
       );
       element._patchRange = {
@@ -1216,8 +1220,7 @@
       assert.isTrue(getReviewedStub.called);
 
       // if prefs are updated then the reviewed status should not be set again
-      setUserModelState({...getUserModelState(),
-        diffPreferences: createDefaultDiffPrefs()});
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
 
       await flush();
       assert.isFalse(saveReviewedStub.called);
@@ -1237,11 +1240,12 @@
             ...createDefaultDiffPrefs(),
             manual_review: false,
           };
-          setUserModelState({...getUserModelState(), diffPreferences});
-          setChangeModelState({change: createChange(),
+          element.userModel.setDiffPreferences(diffPreferences);
+          element.changeModel.setState({
+            change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
-          setRouterModelState({
+          element.routerModel.setState({
             changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF,
             patchNum: 22}
           );
@@ -1262,11 +1266,12 @@
           .callsFake(() => Promise.resolve());
       sinon.stub(element.$.diffHost, 'reload');
 
-      setUserModelState({...getUserModelState(),
-        diffPreferences: createDefaultDiffPrefs()});
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
-      setRouterModelState({
+      element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
       );
 
@@ -2048,7 +2053,6 @@
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
 
       stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
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 7393606..63db013 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
@@ -124,7 +124,7 @@
 // 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 attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+// have, e.g. 'diff-side', 'range', 'line-num'.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3a2def0..71fee62 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -51,7 +51,6 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
-import {changeComments$} from '../../../services/comments/comments-model';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -127,9 +126,15 @@
   private readonly reporting: ReportingService =
     getAppContext().reportingService;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   constructor() {
     super();
-    subscribe(this, changeComments$, x => (this.changeComments = x));
+    subscribe(
+      this,
+      this.commentsModel.changeComments$,
+      x => (this.changeComments = x)
+    );
   }
 
   static override get styles() {
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 d9bc901..e879078 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
@@ -29,13 +29,13 @@
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
-  ChangeInfo,
   PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
   NumericChangeId,
   EditPatchSetNum,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
@@ -90,7 +90,7 @@
   params?: GenerateUrlEditViewParameters;
 
   @property({type: Object, observer: '_editChange'})
-  _change?: ChangeInfo | null;
+  _change?: ParsedChangeInfo | null;
 
   @property({type: Number})
   _changeNum?: NumericChangeId;
@@ -153,13 +153,13 @@
       this._prefs = prefs;
     });
     this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleSaveShortcut(e)
+      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
+        this._handleSaveShortcut()
       )
     );
     this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
-        this._handleSaveShortcut(e)
+      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
+        this._handleSaveShortcut()
       )
     );
   }
@@ -211,13 +211,11 @@
     return Promise.all(promises);
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-    });
+  async _getChangeDetail(changeNum: NumericChangeId) {
+    this._change = await this.restApiService.getChangeDetail(changeNum);
   }
 
-  _editChange(value?: ChangeInfo | null) {
+  _editChange(value?: ParsedChangeInfo | null) {
     if (!value) return;
     if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     fireAlert(
@@ -228,7 +226,7 @@
   }
 
   @observe('_change', '_type')
-  _editType(change?: ChangeInfo | null, type?: string) {
+  _editType(change?: ParsedChangeInfo | null, type?: string) {
     if (!change || !type || !type.startsWith('image/')) return;
 
     // Prevent editing binary files
@@ -402,8 +400,7 @@
     );
   }
 
-  _handleSaveShortcut(e: KeyboardEvent) {
-    e.preventDefault();
+  _handleSaveShortcut() {
     if (!this._saveDisabled) {
       this._saveEdit();
     }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index dc6d8d5..07f3851 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -16,6 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-editor-view';
 import {GrEditorView} from './gr-editor-view';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {HttpMethod} from '../../../constants/constants';
@@ -45,7 +46,7 @@
     element = basicFixture.instantiate();
     savePathStub = stubRestApi('renameFileInChangeEdit');
     saveFileStub = stubRestApi('saveChangeEdit');
-    changeDetailStub = stubRestApi('getDiffChangeDetail');
+    changeDetailStub = stubRestApi('getChangeDetail');
     navigateStub = sinon.stub(element, '_viewEditInChangeView');
   });
 
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c89fe20..d20a42e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -235,10 +235,6 @@
 
   constructor() {
     super();
-    // We just want to instantiate this service somewhere. It is reacting to
-    // model changes and updates the config model, but at the moment the service
-    // is not called from anywhere.
-    getAppContext().configService;
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this._handlePageError(e);
     });
@@ -302,7 +298,6 @@
         patchRange: null,
         selectedFileIndex: 0,
         showReplyDialog: false,
-        showDownloadDialog: false,
         diffMode: null,
         numFilesShown: null,
       },
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index de749df..d0525ea 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -26,10 +26,11 @@
 import {page} from '../utils/page-wrapper-utils';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {AppContext} from '../services/app-context';
 
-export function initGlobalVariables() {
+export function initGlobalVariables(appContext: AppContext) {
   window.GrAnnotation = GrAnnotation;
   window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
-  initGerritPluginApi();
+  initGerritPluginApi(appContext);
 }
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
deleted file mode 100644
index c63df04..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {createAppContext} from '../services/app-context-init';
-import {
-  initVisibilityReporter,
-  initPerformanceReporter,
-  initErrorReporter,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {injectAppContext} from '../services/app-context';
-
-const appContext = createAppContext();
-injectAppContext(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initErrorReporter(reportingService);
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 463fab9..a8da03a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -16,7 +16,6 @@
  */
 
 import {safeTypesBridge} from '../utils/safe-types-util';
-import './gr-app-init';
 import './font-roboto-local-loader';
 // Sets up global Polymer variable, because plugins requires it.
 import '../scripts/bundled-polymer';
@@ -38,10 +37,24 @@
 import './gr-app-element';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-app_html';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
 import {customElement} from '@polymer/decorators';
 import {installPolymerResin} from '../scripts/polymer-resin-install';
 
+import {createAppContext} from '../services/app-context-init';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {injectAppContext} from '../services/app-context';
+
+const appContext = createAppContext();
+injectAppContext(appContext);
+const reportingService = appContext.reportingService;
+initVisibilityReporter(reportingService);
+initPerformanceReporter(reportingService);
+initErrorReporter(reportingService);
+
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
@@ -57,5 +70,4 @@
   }
 }
 
-initGlobalVariables();
-initGerritPluginApi();
+initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..50a2782
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding} from '../../utils/dom-util';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../services/app-context';
+
+interface ShortcutListener {
+  binding: Binding;
+  listener: (e: KeyboardEvent) => void;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+  private readonly service: ShortcutsService = getAppContext().shortcutsService;
+
+  private readonly listenersLocal: ShortcutListener[] = [];
+
+  private readonly listenersGlobal: ShortcutListener[] = [];
+
+  private cleanups: Cleanup[] = [];
+
+  constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+    host.addController(this);
+  }
+
+  // Note that local shortcuts are *not* suppressed when the user has shortcuts
+  // disabled or when the event comes from elements like <input>. So this method
+  // is intended for shortcuts like ESC and Ctrl-ENTER.
+  // If you need suppressed local shortcuts, then just add an options parameter.
+  addLocal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersLocal.push({binding, listener});
+  }
+
+  addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersGlobal.push({binding, listener});
+  }
+
+  hostConnected() {
+    for (const {binding, listener} of this.listenersLocal) {
+      const cleanup = this.service.addShortcut(this.host, binding, listener, {
+        shouldSuppress: false,
+      });
+      this.cleanups.push(cleanup);
+    }
+    for (const {binding, listener} of this.listenersGlobal) {
+      const cleanup = this.service.addShortcut(
+        document.body,
+        binding,
+        listener
+      );
+      this.cleanups.push(cleanup);
+    }
+  }
+
+  hostDisconnected() {
+    for (const cleanup of this.cleanups) {
+      cleanup();
+    }
+    this.cleanups = [];
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
index 9a8f75e..6fd2505 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -18,16 +18,13 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-admin-api tests', () => {
   let adminApi;
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     getPluginLoader().loadPlugins([]);
     adminApi = plugin.admin();
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 2d83012..94eb292 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
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
   is: 'gr-attribute-helper-some-element',
@@ -31,15 +30,13 @@
 
 const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-attribute-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
     instance = plugin.attributeHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 6484f92..e1f3d3c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -43,7 +43,7 @@
 export class GrChecksApi implements ChecksPluginApi {
   private state = State.NOT_REGISTERED;
 
-  private readonly checksService = getAppContext().checksService;
+  private readonly checksModel = getAppContext().checksModel;
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -53,14 +53,14 @@
 
   announceUpdate() {
     this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
-    this.checksService.reload(this.plugin.getPluginName());
+    this.checksModel.reload(this.plugin.getPluginName());
   }
 
   updateResult(run: CheckRun, result: CheckResult) {
     if (result.externalId === undefined) {
       throw new Error('ChecksApi.updateResult() was called without externalId');
     }
-    this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+    this.checksModel.updateResult(this.plugin.getPluginName(), run, result);
   }
 
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
@@ -68,7 +68,7 @@
     if (this.state === State.REGISTERED)
       throw new Error('Only one provider can be registered per plugin.');
     this.state = State.REGISTERED;
-    this.checksService.register(
+    this.checksModel.register(
       this.plugin.getPluginName(),
       provider,
       config ?? DEFAULT_CONFIG
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index e1ec158..596c54b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -17,18 +17,15 @@
 
 import '../../../test/common-test-setup-karma';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {PluginApi} from '../../../api/plugin';
 import {ChecksPluginApi} from '../../../api/checks';
 
-const gerritPluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-settings-api tests', () => {
   let checksApi: ChecksPluginApi | undefined;
 
   setup(() => {
     let pluginApi: PluginApi | undefined = undefined;
-    gerritPluginApi.install(
+    window.Gerrit.install(
       p => {
         pluginApi = p;
       },
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 883f2a6..025f2b4 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
@@ -18,9 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
 import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-dom-hooks tests', () => {
   let instance;
@@ -28,7 +25,7 @@
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrDomHooksManager(plugin);
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 1be5e82..893f0d1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -21,9 +21,6 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
 
 const basicFixture = fixtureFromTemplate(
     html`<div>
@@ -54,7 +51,9 @@
   setup(async () => {
     resetPlugins();
     container = basicFixture.instantiate();
-    pluginApi.install(p => plugin = p, '0.1',
+    window.Gerrit.install(
+        p => { plugin = p; },
+        '0.1',
         'http://some/plugin/url.js');
     // Decoration
     decorationHook = plugin.registerCustomComponent('first', 'some-module');
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 4e3d657..13bd535 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {mockPromise} from '../../../test/test-utils.js';
 
 Polymer({
@@ -34,15 +33,13 @@
 
 const basicFixture = fixtureFromElement('gr-event-helper-some-element');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-event-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
     instance = plugin.eventHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
index a192f80..faf7525 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -18,11 +18,8 @@
 import {resetPlugins} from '../../../test/test-utils.js';
 import './gr-external-style.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 const basicFixture = fixtureFromTemplate(
     html`<gr-external-style name="foo"></gr-external-style>`
 );
@@ -35,7 +32,7 @@
 
   const installPlugin = () => {
     if (plugin) { return; }
-    pluginApi.install(p => {
+    window.Gerrit.install(p => {
       plugin = p;
     }, '0.1', TEST_URL);
   };
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 2889333..beedfab 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
 import {GrPopupInterface} from './gr-popup-interface.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
@@ -34,14 +33,13 @@
 
 const containerFixture = fixtureFromElement('div');
 
-const pluginApi = _testOnly_initGerritPluginApi();
 suite('gr-popup-interface tests', () => {
   let container;
   let instance;
   let plugin;
 
   setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     container = containerFixture.instantiate();
     sinon.stub(plugin, 'hook').returns({
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index f1813a4..282aa11 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -69,6 +69,72 @@
     await flush();
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(`<div class="gr-form-styles">
+    <section>
+      <span class="title"></span>
+      <span class="value">
+        <gr-avatar hidden="" imagesize="120"></gr-avatar>
+      </span>
+    </section>
+    <section class="hide">
+      <span class="title"></span>
+      <span class="value"><a href="">Change avatar</a></span>
+    </section>
+    <section>
+      <span class="title">ID</span>
+      <span class="value">123</span>
+    </section>
+    <section>
+      <span class="title">Email</span>
+      <span class="value">user-123@</span>
+    </section>
+    <section>
+      <span class="title">Registered</span>
+      <span class="value">
+        <gr-date-formatter withtooltip=""></gr-date-formatter>
+      </span>
+    </section>
+    <section id="usernameSection">
+      <span class="title">Username</span>
+      <span class="value"></span>
+      <span class="value" hidden="true">
+        <iron-input id="usernameIronInput">
+          <input id="usernameInput">
+        </iron-input>
+      </span>
+    </section>
+    <section id="nameSection">
+      <label class="title" for="nameInput">Full name</label>
+      <span class="value">User-123</span>
+      <span class="value" hidden="true">
+        <iron-input id="nameIronInput">
+          <input id="nameInput">
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="displayNameInput">Display name</label>
+      <span class="value">
+        <iron-input>
+          <input id="displayNameInput">
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="statusInput">
+        Status (e.g. "Vacation")
+      </label>
+      <span class="value">
+        <iron-input>
+          <input id="statusInput">
+        </iron-input>
+      </span>
+    </section>
+  </div>
+  `);
+  });
+
   test('basic account info render', () => {
     assert.isFalse(element._loading);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 8b47437..0a9fbbf 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -24,6 +24,7 @@
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 @customElement('gr-change-table-editor')
 export class GrChangeTableEditor extends PolymerElement {
@@ -48,24 +49,25 @@
   @observe('serverConfig')
   _configChanged(config: ServerInfo) {
     this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(col, config)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(
-        column,
-        config,
-        this.flagsService.enabledExperiments
-      )
+      this._isColumnEnabled(column, config)
     );
   }
 
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  _isColumnEnabled(column: string, config: ServerInfo) {
     if (!config || !config.change) return true;
-    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
+    if (column === 'Requirements')
+      return this.flagsService.isEnabled(
+        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      );
     return true;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 3813213..c2bcec2 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -104,7 +104,7 @@
 
   test('_getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element._isColumnEnabled(column, element.serverConfig!, [])
+      element._isColumnEnabled(column, element.serverConfig!)
     );
     assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index e7137e4..7216502 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -103,19 +103,19 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+      addShortcut(this, {key: Key.UP}, () => this._handleUp())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+      addShortcut(this, {key: Key.ESC}, () => this._handleEscape())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+      addShortcut(this, {key: Key.TAB}, () => this._handleTab())
     );
   }
 
@@ -141,37 +141,23 @@
     return this.getCursorTarget()?.dataset['value'] || '';
   }
 
-  _handleUp(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorUp();
-    }
+  _handleUp() {
+    if (!this.isHidden) this.cursorUp();
   }
 
-  _handleDown(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorDown();
-    }
+  _handleDown() {
+    if (!this.isHidden) this.cursorDown();
   }
 
   cursorDown() {
-    if (!this.isHidden) {
-      this.cursor.next();
-    }
+    if (!this.isHidden) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) {
-      this.cursor.previous();
-    }
+    if (!this.isHidden) this.cursor.previous();
   }
 
-  _handleTab(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleTab() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
@@ -184,9 +170,7 @@
     );
   }
 
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleEnter() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
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 6d20279..6b8789e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -30,7 +30,8 @@
 
 @customElement('gr-button')
 export class GrButton extends LitElement {
-  private readonly reporting = getAppContext().reportingService;
+  // Private but used in tests.
+  readonly reporting = getAppContext().reportingService;
 
   /**
    * Should this button be rendered as a vote chip? Then we are applying
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index a896382..00b8feb 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -19,7 +19,6 @@
 import '../../../test/common-test-setup-karma';
 import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {getAppContext} from '../../../services/app-context';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrButton} from './gr-button';
 import {pressKey, queryAndAssert} from '../../../test/test-utils';
@@ -190,10 +189,7 @@
   suite('reporting', () => {
     let reportStub: sinon.SinonStub;
     setup(() => {
-      reportStub = sinon.stub(
-        getAppContext().reportingService,
-        'reportInteraction'
-      );
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
       reportStub.reset();
     });
 
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 8251656..a895a5b 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
@@ -19,259 +19,639 @@
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, queryAll, state} from 'lit/decorators';
 import {
   computeDiffFromContext,
-  computeId,
-  DraftInfo,
   isDraft,
   isRobot,
-  sortComments,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  Comment,
+  CommentThread,
+  getLastComment,
+  UnsavedInfo,
+  isDraftOrUnsaved,
+  createUnsavedComment,
+  getFirstComment,
+  createUnsavedReply,
+  isUnsaved,
 } from '../../../utils/comment-util';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
-  CommentSide,
   createDefaultDiffPrefs,
-  Side,
   SpecialFilePath,
 } from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   CommentRange,
-  ConfigInfo,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
-  assertIsDefined,
-  check,
-  queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert, waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
 import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat';
+import {classMap} from 'lit/directives/class-map';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
 
-const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
-export interface GrCommentThread {
-  $: {
-    replyBtn: GrButton;
-    quoteBtn: GrButton;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
+/**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ *     1-based line number or 'FILE' if it refers to the entire file.
+ *
+ * diff-side:
+ *     "left" or "right". These indicate which of the two diffed versions
+ *     the comment relates to. In the case of unified diff, the left
+ *     version is the one whose line number column is further to the left.
+ *
+ * range:
+ *     The range of text that the comment refers to (start_line,
+ *     start_character, end_line, end_character), serialized as JSON. If
+ *     set, range's end_line will have the same value as line-num. Line
+ *     numbers are 1-based, char numbers are 0-based. The start position
+ *     (start_line, start_character) is inclusive, and the end position
+ *     (end_line, end_character) is exclusive.
+ */
 @customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCommentThread extends LitElement {
+  @query('#replyBtn')
+  replyBtn?: GrButton;
+
+  @query('#quoteBtn')
+  quoteBtn?: GrButton;
+
+  @query('.comment-box')
+  commentBox?: HTMLElement;
+
+  @queryAll('gr-comment')
+  commentElements?: NodeList;
+
+  /** Required to be set by parent. */
+  @property()
+  thread?: CommentThread;
 
   /**
-   * gr-comment-thread exposes the following attributes that allow a
-   * diff widget like gr-diff to show the thread in the right location:
+   * Id of the first comment and thus must not change. Will be derived from
+   * the `thread` property in the first willUpdate() cycle.
    *
-   * line-num:
-   *     1-based line number or 'FILE' if it refers to the entire file.
+   * The `rootId` property is also used in gr-diff for maintaining lists and
+   * maps of threads and their associated elements.
    *
-   * diff-side:
-   *     "left" or "right". These indicate which of the two diffed versions
-   *     the comment relates to. In the case of unified diff, the left
-   *     version is the one whose line number column is further to the left.
-   *
-   * range:
-   *     The range of text that the comment refers to (start_line,
-   *     start_character, end_line, end_character), serialized as JSON. If
-   *     set, range's end_line will have the same value as line-num. Line
-   *     numbers are 1-based, char numbers are 0-based. The start position
-   *     (start_line, start_character) is inclusive, and the end position
-   *     (end_line, end_character) is exclusive.
+   * Only stays `undefined` for new threads that only have an unsaved comment.
    */
-  @property({type: Number})
-  changeNum?: NumericChangeId;
-
-  @property({type: Array})
-  comments: UIComment[] = [];
-
-  @property({type: Object, reflectToAttribute: true})
-  range?: CommentRange;
-
-  @property({type: String, reflectToAttribute: true})
-  diffSide?: Side;
-
   @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: String})
-  path: string | undefined;
-
-  @property({type: String, observer: '_projectNameChanged'})
-  projectName?: RepoName;
-
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  hasDraft?: boolean;
-
-  @property({type: Boolean})
-  isOnParent = false;
-
-  @property({type: Number})
-  parentIndex: number | null = null;
-
-  @property({
-    type: String,
-    notify: true,
-    computed: '_computeRootId(comments.*)',
-  })
   rootId?: UrlEncodedCommentId;
 
-  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  // TODO: Is this attribute needed for querySelector() or css rules?
+  // We don't need this internally for the component.
+  @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+  hasDraft?: boolean;
+
+  /** Will be inspected on firstUpdated() only. */
+  @property({type: Boolean, attribute: 'should-scroll-into-view'})
   shouldScrollIntoView = false;
 
-  @property({type: Boolean})
+  /**
+   * Should the file path and line number be rendered above the comment thread
+   * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+   */
+  @property({type: Boolean, attribute: 'show-file-path'})
   showFilePath = false;
 
-  @property({type: Object, reflectToAttribute: true})
-  lineNum?: LineNumber;
+  /**
+   * Only relevant when `showFilePath` is set.
+   * If false, then only the line number is rendered.
+   */
+  @property({type: Boolean, attribute: 'show-file-name'})
+  showFileName = false;
 
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  unresolved?: boolean;
+  @property({type: Boolean, attribute: 'show-ported-comment'})
+  showPortedComment = false;
 
-  @property({type: Boolean})
-  _showActions?: boolean;
+  /** This is set to false by <gr-diff>. */
+  @property({type: Boolean, attribute: false})
+  showPatchset = true;
 
-  @property({type: Object})
-  _lastComment?: UIComment;
+  @property({type: Boolean, attribute: 'show-comment-context'})
+  showCommentContext = false;
 
-  @property({type: Array})
-  _orderedComments: UIComment[] = [];
+  /**
+   * Optional context information when a thread is being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  /**
+   * We are reflecting the editing state of the draft comment here. This is not
+   * an input property, but can be inspected from the parent component.
+   *
+   * Changes to this property are fired as 'comment-thread-editing-changed'
+   * events.
+   */
+  @property({type: Boolean, attribute: 'false'})
+  editing = false;
 
-  @property({type: Object})
-  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+  /**
+   * This can either be an unsaved reply to the last comment or the unsaved
+   * content of a brand new comment thread (then `comments` is empty).
+   * If set, then `thread.comments` must not contain a draft. A thread can only
+   * contain *either* an unsaved comment *or* a draft, not both.
+   */
+  @state()
+  unsavedComment?: UnsavedInfo;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @state()
+  renderPrefs: RenderPreferences = {
     hide_left_side: true,
     disable_context_control_buttons: true,
     show_file_comment_button: false,
     hide_line_length_indicator: true,
   };
 
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
+  @state()
+  repoName?: RepoName;
 
-  @property({type: Boolean})
-  showFileName = true;
+  @state()
+  account?: AccountDetailInfo;
 
-  @property({type: Boolean})
-  showPortedComment = false;
-
-  @property({type: Boolean})
-  showPatchset = true;
-
-  @property({type: Boolean})
-  showCommentContext = false;
-
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  @property({type: Object, computed: 'computeDiff(comments, path)'})
-  _diff?: DiffInfo;
+  /** Computed during willUpdate(). */
+  @state()
+  diff?: DiffInfo;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  /** Computed during willUpdate(). */
+  @state()
+  highlightRange?: CommentRange;
 
-  private readonly reporting = getAppContext().reportingService;
+  /**
+   * Reflects the *dirty* state of whether the thread is currently unresolved.
+   * We are listening on the <gr-comment> of the draft, so we even know when the
+   * checkbox is checked, even if not yet saved.
+   */
+  @state()
+  unresolved = true;
 
-  private readonly commentsService = getAppContext().commentsService;
+  /**
+   * Normally drafts are saved within the <gr-comment> child component and we
+   * don't care about that. But when creating 'Done.' replies we are actually
+   * saving from this component. True while the REST API call is inflight.
+   */
+  @state()
+  saving = false;
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly commentsModel = getAppContext().commentsModel;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly changeModel = getAppContext().changeModel;
 
-  readonly storage = getAppContext().storageService;
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
   constructor() {
     super();
-    this.addEventListener('comment-update', e =>
-      this._handleCommentUpdate(e as CustomEvent)
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+    subscribe(this, this.userModel.diffPreferences$, x =>
+      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
-    this.restApiService.getPreferences().then(prefs => {
-      this._initLayers(!!prefs?.disable_token_highlighting);
+    subscribe(this, this.userModel.preferences$, prefs => {
+      const layers: DiffLayer[] = [this.syntaxLayer];
+      if (!prefs.disable_token_highlighting) {
+        layers.push(new TokenHighlightLayer(this));
+      }
+      this.layers = layers;
     });
-  }
-
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
-    );
-    this.cleanups.push(
-      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
-    );
-    this._getLoggedIn().then(loggedIn => {
-      this._showActions = loggedIn;
-    });
-    this.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      this._prefs = {
+    subscribe(this, this.userModel.diffPreferences$, prefs => {
+      this.prefs = {
         ...prefs,
         // set line_wrapping to true so that the context can take all the
         // remaining space after comment card has rendered
         line_wrapping: true,
       };
-      this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    this._setInitialExpandedState();
+    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
   }
 
-  computeDiff(comments?: UIComment[], path?: string) {
-    if (comments === undefined || path === undefined) return undefined;
-    if (!comments[0]?.context_lines?.length) return undefined;
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          font-family: var(--font-family);
+          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. 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: var(--background-color-primary);
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        gr-comment {
+          border-bottom: 1px solid var(--comment-separator-color);
+        }
+        #actions {
+          margin-left: auto;
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        .comment-box {
+          width: 80ch;
+          max-width: 100%;
+          background-color: var(--comment-background-color);
+          color: var(--comment-text-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          flex-shrink: 0;
+        }
+        #container {
+          display: var(--gr-comment-thread-display, flex);
+          align-items: flex-start;
+          margin: 0 var(--spacing-s) var(--spacing-s);
+          white-space: normal;
+          /** This is required for firefox to continue the inheritance */
+          -webkit-user-select: inherit;
+          -moz-user-select: inherit;
+          -ms-user-select: inherit;
+          user-select: inherit;
+        }
+        .comment-box.unresolved {
+          background-color: var(--unresolved-comment-background-color);
+        }
+        .comment-box.robotComment {
+          background-color: var(--robot-comment-background-color);
+        }
+        #actionsContainer {
+          display: flex;
+        }
+        .comment-box.saving #actionsContainer {
+          opacity: 0.5;
+        }
+        #unresolvedLabel {
+          font-family: var(--font-family);
+          margin: auto 0;
+          padding: var(--spacing-m);
+        }
+        .pathInfo {
+          display: flex;
+          align-items: baseline;
+          justify-content: space-between;
+          padding: 0 var(--spacing-s) var(--spacing-s);
+        }
+        .fileName {
+          padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+        }
+        @media only screen and (max-width: 1200px) {
+          .diff-container {
+            display: none;
+          }
+        }
+        .diff-container {
+          margin-left: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          flex-grow: 1;
+          flex-shrink: 1;
+          max-width: 1200px;
+        }
+        .view-diff-button {
+          margin: var(--spacing-s) var(--spacing-m);
+        }
+        .view-diff-container {
+          border-top: 1px solid var(--border-color);
+          background-color: var(--background-color-primary);
+        }
+
+        /* In saved state the "reply" and "quote" buttons are 28px height.
+         * top:4px  positions the 20px icon vertically centered.
+         * Currently in draft state the "save" and "cancel" buttons are 20px
+         * height, so the link icon does not need a top:4px in gr-comment_html.
+         */
+        .link-icon {
+          position: relative;
+          top: 4px;
+          cursor: pointer;
+        }
+        .fileName gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: top;
+          --gr-button-padding: 0px;
+        }
+        .fileName:focus-within gr-copy-clipboard,
+        .fileName:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.thread) return;
+    const dynamicBoxClasses = {
+      robotComment: this.isRobotComment(),
+      unresolved: this.unresolved,
+      saving: this.saving,
+    };
+    return html`
+      ${this.renderFilePath()}
+      <div id="container">
+        <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+        <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+          ${this.renderComments()} ${this.renderActions()}
+        </div>
+        ${this.renderContextualDiff()}
+      </div>
+    `;
+  }
+
+  renderFilePath() {
+    if (!this.showFilePath) return;
+    const href = this.getUrlForComment();
+    const line = this.computeDisplayLine();
+    return html`
+      ${this.renderFileName()}
+      <div class="pathInfo">
+        ${href
+          ? html`<a href="${href}">${line}</a>`
+          : html`<span>${line}</span>`}
+      </div>
+    `;
+  }
+
+  renderFileName() {
+    if (!this.showFileName) return;
+    if (this.isPatchsetLevel()) {
+      return html`<div class="fileName"><span>Patchset</span></div>`;
+    }
+    const href = this.getDiffUrlForPath();
+    const displayPath = this.getDisplayPath();
+    return html`
+      <div class="fileName">
+        ${href
+          ? html`<a href="${href}">${displayPath}</a>`
+          : html`<span>${displayPath}</span>`}
+        <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+      </div>
+    `;
+  }
+
+  renderComments() {
+    assertIsDefined(this.thread, 'thread');
+    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const comments: Comment[] = [...this.thread.comments];
+    if (this.unsavedComment && !this.isDraft()) {
+      comments.push(this.unsavedComment);
+    }
+    return repeat(
+      comments,
+      // We want to reuse <gr-comment> when unsaved changes to draft.
+      comment => (isDraftOrUnsaved(comment) ? 'unsaved' : comment.id),
+      comment => {
+        const initiallyCollapsed =
+          !isDraftOrUnsaved(comment) &&
+          (this.messageId
+            ? comment.change_message_id !== this.messageId
+            : !this.unresolved);
+        return html`
+          <gr-comment
+            .comment="${comment}"
+            .comments="${this.thread!.comments}"
+            .patchNum="${this.thread?.patchNum}"
+            ?initially-collapsed="${initiallyCollapsed}"
+            ?robot-button-disabled="${robotButtonDisabled}"
+            ?show-patchset="${this.showPatchset}"
+            ?show-ported-comment="${this.showPortedComment &&
+            comment.id === this.rootId}"
+            @create-fix-comment="${this.handleCommentFix}"
+            @copy-comment-link="${this.handleCopyLink}"
+            @comment-editing-changed="${(e: CustomEvent) => {
+              if (isDraftOrUnsaved(comment)) this.editing = e.detail;
+            }}"
+            @comment-unresolved-changed="${(e: CustomEvent) => {
+              if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
+            }}"
+          ></gr-comment>
+        `;
+      }
+    );
+  }
+
+  renderActions() {
+    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+      return;
+    return html`
+      <div id="actionsContainer">
+        <span id="unresolvedLabel">${
+          this.unresolved ? 'Unresolved' : 'Resolved'
+        }</span>
+        <div id="actions">
+          <iron-icon
+              class="link-icon copy"
+              @click="${this.handleCopyLink}"
+              title="Copy link to this comment"
+              icon="gr-icons:link"
+              role="button"
+              tabindex="0"
+          >
+          </iron-icon>
+          <gr-button
+              id="replyBtn"
+              link
+              class="action reply"
+              ?disabled="${this.saving}"
+              @click="${() => this.handleCommentReply(false)}"
+          >Reply</gr-button
+          >
+          <gr-button
+              id="quoteBtn"
+              link
+              class="action quote"
+              ?disabled="${this.saving}"
+              @click="${() => this.handleCommentReply(true)}"
+          >Quote</gr-button
+          >
+          ${
+            this.unresolved
+              ? html`
+                  <gr-button
+                    id="ackBtn"
+                    link
+                    class="action ack"
+                    ?disabled="${this.saving}"
+                    @click="${this.handleCommentAck}"
+                    >Ack</gr-button
+                  >
+                  <gr-button
+                    id="doneBtn"
+                    link
+                    class="action done"
+                    ?disabled="${this.saving}"
+                    @click="${this.handleCommentDone}"
+                    >Done</gr-button
+                  >
+                `
+              : ''
+          }
+        </div>
+      </div>
+      </div>
+    `;
+  }
+
+  renderContextualDiff() {
+    if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+    if (!this.thread?.path) return;
+    const href = this.getUrlForComment();
+    return html`
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          .changeNum="${this.changeNum}"
+          .diff="${this.diff}"
+          .layers="${this.layers}"
+          .path="${this.thread.path}"
+          .prefs="${this.prefs}"
+          .renderPrefs="${this.renderPrefs}"
+          .highlightRange="${this.highlightRange}"
+        >
+        </gr-diff>
+        <div class="view-diff-container">
+          <a href="${href}">
+            <gr-button link class="view-diff-button">View Diff</gr-button>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (!this.thread) return;
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    if (this.getFirstComment() === undefined) {
+      this.unsavedComment = createUnsavedComment(this.thread);
+    }
+    this.unresolved = this.getLastComment()?.unresolved ?? true;
+    this.diff = this.computeDiff();
+    this.highlightRange = this.computeHighlightRange();
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('thread')) {
+      if (!this.isDraftOrUnsaved()) {
+        // We can only do this for threads without draft, because otherwise we
+        // are relying on the <gr-comment> component for the draft to fire
+        // events about the *dirty* `unresolved` state.
+        this.unresolved = this.getLastComment()?.unresolved ?? true;
+      }
+      this.hasDraft = this.isDraftOrUnsaved();
+      this.rootId = this.getFirstComment()?.id;
+      if (this.isDraft()) {
+        this.unsavedComment = undefined;
+      }
+    }
+    if (changed.has('editing')) {
+      // changed.get('editing') contains the old value. We only want to trigger
+      // when changing from editing to non-editing (user has cancelled/saved).
+      // We do *not* want to trigger on first render (old value is `null`)
+      if (!this.editing && changed.get('editing') === true) {
+        this.unsavedComment = undefined;
+        if (this.thread?.comments.length === 0) {
+          this.remove();
+        }
+      }
+      fire(this, 'comment-thread-editing-changed', {value: this.editing});
+    }
+  }
+
+  override firstUpdated() {
+    if (this.shouldScrollIntoView) {
+      this.commentBox?.focus();
+      this.scrollIntoView();
+    }
+  }
+
+  private isDraft() {
+    return isDraft(this.getLastComment());
+  }
+
+  private isDraftOrUnsaved(): boolean {
+    return this.isDraft() || this.isUnsaved();
+  }
+
+  private isNewThread(): boolean {
+    return this.thread?.comments.length === 0;
+  }
+
+  private isUnsaved(): boolean {
+    return !!this.unsavedComment || this.thread?.comments.length === 0;
+  }
+
+  private isPatchsetLevel() {
+    return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  }
+
+  private computeDiff() {
+    if (!this.showCommentContext) return;
+    if (!this.thread?.path) return;
+    const firstComment = this.getFirstComment();
+    if (!firstComment?.context_lines?.length) return;
     const diff = computeDiffFromContext(
-      comments[0].context_lines,
-      path,
-      comments[0].source_content_type
+      firstComment.context_lines,
+      this.thread?.path,
+      firstComment.source_content_type
     );
     // Do we really have to re-compute (and re-render) the diff?
-    if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
-      return this._diff;
+    if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+      return this.diff;
     }
 
     if (!anyLineTooLong(diff)) {
@@ -283,83 +663,21 @@
     return diff;
   }
 
-  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
-    // Wait for comment to be rendered before scrolling to it
-    if (shouldScrollIntoView) {
-      const resizeObserver = new ResizeObserver(
-        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
-          if (this.offsetHeight > 0) {
-            queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
-            this.scrollIntoView();
-          }
-          observer.unobserve(this);
-        }
-      );
-      resizeObserver.observe(this);
+  private getDiffUrlForPath() {
+    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+      return undefined;
     }
+    if (this.isNewThread()) return undefined;
+    return GerritNav.getUrlForDiffById(
+      this.changeNum,
+      this.repoName,
+      this.thread.path,
+      this.thread.patchNum
+    );
   }
 
-  _shouldShowCommentContext(
-    changeNum?: NumericChangeId,
-    showCommentContext?: boolean,
-    diff?: DiffInfo
-  ) {
-    return changeNum && showCommentContext && !!diff;
-  }
-
-  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
-    const lastComment = this.comments[this.comments.length - 1] || {};
-    if (isDraft(lastComment)) {
-      const commentEl = this._commentElWithDraftID(
-        lastComment.id || lastComment.__draftID
-      );
-      if (!commentEl) throw new Error('Failed to find draft.');
-      commentEl.editing = true;
-
-      // If the comment was collapsed, re-open it to make it clear which
-      // actions are available.
-      commentEl.collapsed = false;
-    } else {
-      const range = rangeParam
-        ? rangeParam
-        : lastComment
-        ? lastComment.range
-        : undefined;
-      const unresolved = lastComment ? lastComment.unresolved : undefined;
-      this.addDraft(lineNum, range, unresolved);
-    }
-  }
-
-  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
-    const draft = this._newDraft(lineNum, range);
-    draft.__editing = true;
-    draft.unresolved = unresolved === false ? unresolved : true;
-    this.commentsService.addDraft(draft);
-  }
-
-  _getDiffUrlForPath(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!changeNum || !projectName || !path) return undefined;
-    if (isDraft(this.comments[0])) {
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  /** The parameter is for triggering re-computation only. */
-  getHighlightRange(_: unknown) {
-    const comment = this.comments?.[0];
+  private computeHighlightRange() {
+    const comment = this.getFirstComment();
     if (!comment) return undefined;
     if (comment.range) return comment.range;
     if (comment.line) {
@@ -373,413 +691,130 @@
     return undefined;
   }
 
-  _initLayers(disableTokenHighlighting: boolean) {
-    if (!disableTokenHighlighting) {
-      this.layers.push(new TokenHighlightLayer(this));
+  private getUrlForComment() {
+    if (!this.repoName || !this.changeNum || this.isNewThread()) {
+      return undefined;
     }
-    this.layers.push(this.syntaxLayer);
-  }
-
-  _getUrlForViewDiff(
-    comments: UIComment[],
-    changeNum?: NumericChangeId,
-    projectName?: RepoName
-  ): string {
-    if (!changeNum) return '';
-    if (!projectName) return '';
-    check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
-  }
-
-  _getDiffUrlForComment(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!projectName || !changeNum || !path) return undefined;
-    if (
-      (this.comments.length && this.comments[0].side === 'PARENT') ||
-      isDraft(this.comments[0])
-    ) {
-      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum,
-        undefined,
-        this.lineNum === FILE ? undefined : this.lineNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  handleCopyLink() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
-    const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(
-        this.changeNum,
-        this.projectName,
-        this.comments[0].id!
-      )
+    assertIsDefined(this.rootId, 'rootId of comment thread');
+    return GerritNav.getUrlForComment(
+      this.changeNum,
+      this.repoName,
+      this.rootId
     );
-    navigator.clipboard.writeText(url).then(() => {
+  }
+
+  private handleCopyLink() {
+    const url = this.getUrlForComment();
+    assertIsDefined(url, 'url for comment');
+    navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
       fireAlert(this, 'Link copied to clipboard');
     });
   }
 
-  _isPatchsetLevelComment(path?: string) {
-    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  private getDisplayPath() {
+    if (this.isPatchsetLevel()) return 'Patchset';
+    return computeDisplayPath(this.thread?.path);
   }
 
-  _computeShowPortedComment(comment: UIComment) {
-    if (this._orderedComments.length === 0) return false;
-    return this.showPortedComment && comment.id === this._orderedComments[0].id;
-  }
-
-  _computeDisplayPath(path?: string) {
-    const displayPath = computeDisplayPath(path);
-    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return 'Patchset';
-    }
-    return displayPath;
-  }
-
-  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
-    if (lineNum === FILE) {
-      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return '';
-      }
-      return FILE;
-    }
-    if (lineNum) return `#${lineNum}`;
+  private computeDisplayLine() {
+    assertIsDefined(this.thread, 'thread');
+    if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+    if (this.thread.line) return `#${this.thread.line}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (range) return `#${range.end_line}`;
+    if (this.thread.range) return `#${this.thread.range.end_line}`;
     return '';
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  private isRobotComment() {
+    return isRobot(this.getLastComment());
   }
 
-  _getUnresolvedLabel(unresolved?: boolean) {
-    return unresolved ? 'Unresolved' : 'Resolved';
+  private getFirstComment() {
+    assertIsDefined(this.thread);
+    return getFirstComment(this.thread);
   }
 
-  @observe('comments.*')
-  _commentsChanged() {
-    this._orderedComments = sortComments(this.comments);
-    this.updateThreadProperties();
+  private getLastComment() {
+    assertIsDefined(this.thread);
+    return getLastComment(this.thread);
   }
 
-  updateThreadProperties() {
-    if (this._orderedComments.length) {
-      this._lastComment = this._getLastComment();
-      this.unresolved = this._lastComment.unresolved;
-      this.hasDraft = isDraft(this._lastComment);
-      this.isRobotComment = isRobot(this._lastComment);
+  private handleExpandShortcut() {
+    this.expandCollapseComments(false);
+  }
+
+  private handleCollapseShortcut() {
+    this.expandCollapseComments(true);
+  }
+
+  private expandCollapseComments(actionIsCollapse: boolean) {
+    for (const comment of this.commentElements ?? []) {
+      (comment as GrComment).collapsed = actionIsCollapse;
     }
   }
 
-  _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
-    return !_showActions || !_lastComment || isDraft(_lastComment);
-  }
-
-  _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
-    return (
-      this._shouldDisableAction(_showActions, _lastComment) ||
-      isRobot(_lastComment)
-    );
-  }
-
-  _getLastComment() {
-    return this._orderedComments[this._orderedComments.length - 1] || {};
-  }
-
-  private handleExpandShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(false);
-  }
-
-  private handleCollapseShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(true);
-  }
-
-  _expandCollapseComments(actionIsCollapse: boolean) {
-    const comments = this.root?.querySelectorAll('gr-comment');
-    if (!comments) return;
-    for (const comment of comments) {
-      comment.collapsed = actionIsCollapse;
+  private async createReplyComment(
+    content: string,
+    userWantsToEdit: boolean,
+    unresolved: boolean
+  ) {
+    const replyingTo = this.getLastComment();
+    assertIsDefined(this.thread, 'thread');
+    assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+    if (isDraft(replyingTo)) {
+      throw new Error('cannot reply to draft');
     }
-  }
-
-  /**
-   * Sets the initial state of the comment thread.
-   * Expands the thread if one of the following is true:
-   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-   * thread is unresolved,
-   * - it's a robot comment.
-   * - it's a draft
-   */
-  _setInitialExpandedState() {
-    if (this._orderedComments) {
-      for (let i = 0; i < this._orderedComments.length; i++) {
-        const comment = this._orderedComments[i];
-        if (isDraft(comment)) {
-          comment.collapsed = false;
-          continue;
-        }
-        const isRobotComment = !!(comment as UIRobot).robot_id;
-        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-        const resolvedThread =
-          !this.unresolved ||
-          this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-        if (comment.collapsed === undefined) {
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
+    if (isUnsaved(replyingTo)) {
+      throw new Error('cannot reply to unsaved comment');
+    }
+    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    if (userWantsToEdit) {
+      this.unsavedComment = unsaved;
+    } else {
+      try {
+        this.saving = true;
+        await this.commentsModel.saveDraft(unsaved);
+      } finally {
+        this.saving = false;
       }
     }
   }
 
-  _createReplyComment(
-    content?: string,
-    isEditing?: boolean,
-    unresolved?: boolean
-  ) {
-    this.reporting.recordDraftInteraction();
-    const id = this._orderedComments[this._orderedComments.length - 1].id;
-    if (!id) throw new Error('Cannot reply to comment without id.');
-    const reply = this._newReply(id, content, unresolved);
-
-    if (isEditing) {
-      reply.__editing = true;
-      this.commentsService.addDraft(reply);
-    } else {
-      assertIsDefined(this.changeNum, 'changeNum');
-      assertIsDefined(this.patchNum, 'patchNum');
-      this.restApiService
-        .saveDiffDraft(this.changeNum, this.patchNum, reply)
-        .then(result => {
-          if (!result.ok) {
-            fireAlert(document, 'Unable to restore draft');
-            return;
-          }
-          this.restApiService.getResponseObject(result).then(obj => {
-            const resComment = obj as unknown as DraftInfo;
-            resComment.patch_set = reply.patch_set;
-            this.commentsService.addDraft(resComment);
-          });
-        });
-    }
-  }
-
-  _isDraft(comment: UIComment) {
-    return isDraft(comment);
-  }
-
-  _processCommentReply(quote?: boolean) {
-    const comment = this._lastComment;
+  private handleCommentReply(quote: boolean) {
+    const comment = this.getLastComment();
     if (!comment) throw new Error('Failed to find last comment.');
-    let content = undefined;
+    let content = '';
     if (quote) {
       const msg = comment.message;
       if (!msg) throw new Error('Quoting empty comment.');
       content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     }
-    this._createReplyComment(content, true, comment.unresolved);
+    this.createReplyComment(content, true, comment.unresolved ?? true);
   }
 
-  _handleCommentReply() {
-    this._processCommentReply();
+  private handleCommentAck() {
+    this.createReplyComment('Ack', false, false);
   }
 
-  _handleCommentQuote() {
-    this._processCommentReply(true);
+  private handleCommentDone() {
+    this.createReplyComment('Done', false, false);
   }
 
-  _handleCommentAck() {
-    this._createReplyComment('Ack', false, false);
-  }
-
-  _handleCommentDone() {
-    this._createReplyComment('Done', false, false);
-  }
-
-  _handleCommentFix(e: CustomEvent) {
+  private handleCommentFix(e: CustomEvent) {
     const comment = e.detail.comment;
     const msg = comment.message;
     const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
     const quoteStr = '> ' + quoted + '\n\n';
     const response = quoteStr + 'Please fix.';
-    this._createReplyComment(response, false, true);
+    this.createReplyComment(response, false, true);
   }
 
-  _commentElWithDraftID(id?: string): GrComment | null {
-    if (!id) return null;
-    const els = this.root?.querySelectorAll('gr-comment');
-    if (!els) return null;
-    for (const el of els) {
-      const c = el.comment;
-      if (isRobot(c)) continue;
-      if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
-    }
-    return null;
-  }
-
-  _newReply(
-    inReplyTo: UrlEncodedCommentId,
-    message?: string,
-    unresolved?: boolean
-  ) {
-    const d = this._newDraft();
-    d.in_reply_to = inReplyTo;
-    if (message !== undefined) {
-      d.message = message;
-    }
-    if (unresolved !== undefined) {
-      d.unresolved = unresolved;
-    }
-    return d;
-  }
-
-  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
-    const d: UIDraft = {
-      __draft: true,
-      __draftID: 'draft__' + Math.random().toString(36),
-      __date: new Date(),
-    };
-    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
-    // For replies, always use same meta info as root.
-    if (this.comments && this.comments.length >= 1) {
-      const rootComment = this.comments[0];
-      if (rootComment.path !== undefined) d.path = rootComment.path;
-      if (rootComment.patch_set !== undefined)
-        d.patch_set = rootComment.patch_set;
-      if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.line !== undefined) d.line = rootComment.line;
-      if (rootComment.range !== undefined) d.range = rootComment.range;
-      if (rootComment.parent !== undefined) d.parent = rootComment.parent;
-    } else {
-      // Set meta info for root comment.
-      d.path = this.path;
-      d.patch_set = this.patchNum;
-      d.side = this._getSide(this.isOnParent);
-
-      if (lineNum && lineNum !== FILE) {
-        d.line = lineNum;
-      }
-      if (range) {
-        d.range = range;
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-    }
-    return d;
-  }
-
-  _getSide(isOnParent: boolean): CommentSide {
-    return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
-  }
-
-  _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
-    // Keep the root ID even if the comment was removed, so that notification
-    // to sync will know which thread to remove.
-    if (!comments.base.length) {
-      return this.rootId;
-    }
-    return computeId(comments.base[0]);
-  }
-
-  _handleCommentDiscard() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchNum, 'patchNum');
-    // Check to see if there are any other open comments getting edited and
-    // set the local storage value to its message value.
-    for (const changeComment of this.comments) {
-      if (isDraft(changeComment) && changeComment.__editing) {
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum: this.patchNum,
-          path: changeComment.path,
-          line: changeComment.line,
-        };
-        this.storage.setDraftComment(
-          commentLocation,
-          changeComment.message ?? ''
-        );
-      }
-    }
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const index = this._indexOf(comment, this.comments);
-    if (index === -1) {
-      // This should never happen: comment belongs to another thread.
-      this.reporting.error(
-        new Error(`Comment update for another comment thread: ${comment}`)
-      );
-      return;
-    }
-    this.set(['comments', index], comment);
-    // Because of the way we pass these comment objects around by-ref, in
-    // combination with the fact that Polymer does dirty checking in
-    // observers, the this.set() call above will not cause a thread update in
-    // some situations.
-    this.updateThreadProperties();
-  }
-
-  _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
-    if (!comment) return -1;
-    for (let i = 0; i < arr.length; i++) {
-      const c = arr[i];
-      if (
-        (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
-        (c.id && c.id === comment.id)
-      ) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /** 2nd parameter is for triggering re-computation only. */
-  _computeHostClass(unresolved?: boolean, _?: unknown) {
-    if (this.isRobotComment) {
-      return 'robotComment';
-    }
-    return unresolved ? 'unresolved' : '';
-  }
-
-  /**
-   * Load the project config when a project name has been provided.
-   *
-   * @param name The project name.
-   */
-  _projectNameChanged(name?: RepoName) {
-    if (!name) {
-      return;
-    }
-    this.restApiService.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeAriaHeading(_orderedComments: UIComment[]) {
-    const firstComment = _orderedComments[0];
-    const author = firstComment?.author ?? this._selfAccount;
-    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
-    const status = [
-      lastComment.unresolved ? 'Unresolved' : '',
-      isDraft(lastComment) ? 'Draft' : '',
-    ].join(' ');
-    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  private computeAriaHeading() {
+    const author = this.getFirstComment()?.author ?? this.account;
+    const user = getUserName(undefined, author);
+    const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
 
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
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      font-family: var(--font-family);
-      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. 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: var(--background-color-primary);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    gr-comment {
-      border-bottom: 1px solid var(--comment-separator-color);
-    }
-    #actions {
-      margin-left: auto;
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    .comment-box {
-      width: 80ch;
-      max-width: 100%;
-      background-color: var(--comment-background-color);
-      color: var(--comment-text-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      flex-shrink: 0;
-    }
-    #container {
-      display: var(--gr-comment-thread-display, flex);
-      align-items: flex-start;
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
-      /** This is required for firefox to continue the inheritance */
-      -webkit-user-select: inherit;
-      -moz-user-select: inherit;
-      -ms-user-select: inherit;
-      user-select: inherit;
-    }
-    .comment-box.unresolved {
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .comment-box.robotComment {
-      background-color: var(--robot-comment-background-color);
-    }
-    #commentInfoContainer {
-      display: flex;
-    }
-    #unresolvedLabel {
-      font-family: var(--font-family);
-      margin: auto 0;
-      padding: var(--spacing-m);
-    }
-    .pathInfo {
-      display: flex;
-      align-items: baseline;
-      justify-content: space-between;
-      padding: 0 var(--spacing-s) var(--spacing-s);
-    }
-    .fileName {
-      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
-    }
-    @media only screen and (max-width: 1200px) {
-      .diff-container {
-        display: none;
-      }
-    }
-    .diff-container {
-      margin-left: var(--spacing-l);
-      border: 1px solid var(--border-color);
-      flex-grow: 1;
-      flex-shrink: 1;
-      max-width: 1200px;
-    }
-    .view-diff-button {
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .view-diff-container {
-      border-top: 1px solid var(--border-color);
-      background-color: var(--background-color-primary);
-    }
-
-    /* In saved state the "reply" and "quote" buttons are 28px height.
-     * top:4px  positions the 20px icon vertically centered.
-     * Currently in draft state the "save" and "cancel" buttons are 20px
-     * height, so the link icon does not need a top:4px in gr-comment_html.
-     */
-    .link-icon {
-      position: relative;
-      top: 4px;
-      cursor: pointer;
-    }
-    .fileName gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: top;
-      --gr-button-padding: 0px;
-    }
-    .fileName:focus-within gr-copy-clipboard,
-    .fileName:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-  </style>
-
-  <template is="dom-if" if="[[showFilePath]]">
-    <template is="dom-if" if="[[showFileName]]">
-      <div class="fileName">
-        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
-          <span> [[_computeDisplayPath(path)]] </span>
-        </template>
-        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a
-            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
-          >
-            [[_computeDisplayPath(path)]]
-          </a>
-          <gr-copy-clipboard
-            hideInput=""
-            text="[[_computeDisplayPath(path)]]"
-          ></gr-copy-clipboard>
-        </template>
-      </div>
-    </template>
-    <div class="pathInfo">
-      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-        <a
-          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine(lineNum, range)]]</a
-        >
-      </template>
-    </div>
-  </template>
-  <div id="container">
-    <h3 class="assistive-tech-only">
-      [[_computeAriaHeading(_orderedComments)]]
-    </h3>
-    <div
-      class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
-      tabindex="0"
-    >
-      <template
-        id="commentList"
-        is="dom-repeat"
-        items="[[_orderedComments]]"
-        as="comment"
-      >
-        <gr-comment
-          comment="{{comment}}"
-          comments="{{comments}}"
-          robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-          change-num="[[changeNum]]"
-          project-name="[[projectName]]"
-          patch-num="[[patchNum]]"
-          draft="[[_isDraft(comment)]]"
-          show-actions="[[_showActions]]"
-          show-patchset="[[showPatchset]]"
-          show-ported-comment="[[_computeShowPortedComment(comment)]]"
-          side="[[comment.side]]"
-          project-config="[[_projectConfig]]"
-          on-create-fix-comment="_handleCommentFix"
-          on-comment-discard="_handleCommentDiscard"
-          on-copy-comment-link="handleCopyLink"
-        ></gr-comment>
-      </template>
-      <div
-        id="commentInfoContainer"
-        hidden$="[[_hideActions(_showActions, _lastComment)]]"
-      >
-        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
-        <div id="actions">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-          <gr-button
-            id="replyBtn"
-            link=""
-            class="action reply"
-            on-click="_handleCommentReply"
-            >Reply</gr-button
-          >
-          <gr-button
-            id="quoteBtn"
-            link=""
-            class="action quote"
-            on-click="_handleCommentQuote"
-            >Quote</gr-button
-          >
-          <template is="dom-if" if="[[unresolved]]">
-            <gr-button
-              id="ackBtn"
-              link=""
-              class="action ack"
-              on-click="_handleCommentAck"
-              >Ack</gr-button
-            >
-            <gr-button
-              id="doneBtn"
-              link=""
-              class="action done"
-              on-click="_handleCommentDone"
-              >Done</gr-button
-            >
-          </template>
-        </div>
-      </div>
-    </div>
-    <template
-      is="dom-if"
-      if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
-    >
-      <div class="diff-container">
-        <gr-diff
-          id="diff"
-          change-num="[[changeNum]]"
-          diff="[[_diff]]"
-          layers="[[layers]]"
-          path="[[path]]"
-          prefs="[[_prefs]]"
-          render-prefs="[[_renderPrefs]]"
-          highlight-range="[[getHighlightRange(comments)]]"
-        >
-        </gr-diff>
-        <div class="view-diff-container">
-          <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
-            <gr-button link class="view-diff-button">View Diff</gr-button>
-          </a>
-        </div>
-      </div>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index a4664ee..347e1e0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,1005 +14,361 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
-  sortComments,
-  UIComment,
-  UIRobot,
-  UIDraft,
-} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
-  PatchSetNum,
   NumericChangeId,
   UrlEncodedCommentId,
   Timestamp,
-  RobotId,
-  RobotRunId,
+  CommentInfo,
   RepoName,
-  ConfigInfo,
-  EmailAddress,
 } from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
-import {
-  tap,
-  pressAndReleaseKeyOn,
-} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   mockPromise,
+  queryAndAssert,
   stubComments,
-  stubReporting,
   stubRestApi,
+  waitUntilCalled,
+  MockPromise,
 } from '../../../test/test-utils';
-import {createComment} from '../../../test/test-data-generators';
+import {
+  createAccountDetailWithId,
+  createThread,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonStub} from 'sinon';
+import {waitUntil} from '@open-wc/testing-helpers';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+  author: {name: 'Kermit'},
+  id: 'the-root' as UrlEncodedCommentId,
+  message: 'start the conversation',
+  updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2 = {
+  author: {name: 'Ms Piggy'},
+  id: 'the-reply' as UrlEncodedCommentId,
+  message: 'keep it going',
+  updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'stop it',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-reply' as UrlEncodedCommentId,
+  __draft: true,
+};
+
+const commentWithContext = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'just for context',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  line: 5,
+  context_lines: [
+    {line_number: 4, context_line: 'content of line 4'},
+    {line_number: 5, context_line: 'content of line 5'},
+    {line_number: 6, context_line: 'content of line 6'},
+  ],
+};
 
 suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element: GrCommentThread;
+  let element: GrCommentThread;
 
-    setup(async () => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      element.patchNum = 3 as PatchSetNum;
-      element.changeNum = 1 as NumericChangeId;
-      await flush();
-    });
+  setup(async () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+    element.changeNum = 1 as NumericChangeId;
+    element.showFileName = true;
+    element.showFilePath = true;
+    element.repoName = 'test-repo-name' as RepoName;
+    await element.updateComplete;
+    element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+  });
 
-    test('renders', async () => {
-      element.comments = [
-        {
-          ...createComment(),
-          author: {name: 'Kermit'},
-          id: 'the-root' as UrlEncodedCommentId,
-          message: 'start the conversation',
-          updated: '2021-11-01 10:11:12.000000000' as Timestamp,
-        },
-        {
-          ...createComment(),
-          author: {name: 'Ms Piggy'},
-          id: 'the-reply' as UrlEncodedCommentId,
-          message: 'keep it going',
-          updated: '2021-11-02 10:11:12.000000000' as Timestamp,
-          in_reply_to: 'the-root' as UrlEncodedCommentId,
-        },
-        {
-          ...createComment(),
-          author: {name: 'Kermit'},
-          id: 'the-draft' as UrlEncodedCommentId,
-          message: 'stop it',
-          updated: '2021-11-03 10:11:12.000000000' as Timestamp,
-          in_reply_to: 'the-reply' as UrlEncodedCommentId,
-          __draft: true,
-        },
-      ];
-      await flush();
-      expect(element).shadowDom.to.equal(`
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+  });
+
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <span>#314</span>
+        </div>
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
           <div class="comment-box" tabindex="0">
-            <gr-comment></gr-comment>
-            <gr-comment></gr-comment>
-            <gr-comment></gr-comment>
-            <dom-repeat as="comment" id="commentList" style="display: none;">
-              <template is="dom-repeat"></template>
-            </dom-repeat>
-            <div hidden="true" id="commentInfoContainer">
-              <span id="unresolvedLabel">Resolved</span>
+            <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `);
+  });
+
+  test('renders unsaved', async () => {
+    element.thread = createThread();
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <span>#314</span>
+        </div>
+        <div id="container">
+          <h3 class="assistive-tech-only">Unresolved Draft Comment thread by Yoda</h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `);
+  });
+
+  test('renders with actions resolved', async () => {
+    element.thread = createThread(c1, c2);
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(`
+        <div id="container">
+          <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+          <div class="comment-box" tabindex="0">
+            <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+            <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel">
+                Resolved
+              </span>
               <div id="actions">
-                <iron-icon
-                  class="link-icon"
-                  icon="gr-icons:link"
-                  role="button"
-                  tabindex="0"
-                  title="Copy link to this comment"
-                >
+                <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
                 </iron-icon>
-                <gr-button
-                  aria-disabled="false"
-                  class="action reply"
-                  id="replyBtn"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
+                <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
                   Reply
                 </gr-button>
-                <gr-button
-                  aria-disabled="false"
-                  class="action quote"
-                  id="quoteBtn"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
+                <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
                   Quote
                 </gr-button>
-                <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
               </div>
             </div>
           </div>
-          <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
         </div>
       `);
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments: UIComment[] = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          message: 'i like you, too' as UrlEncodedCommentId,
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          __draft: true,
-        },
-      ];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        false
-      );
-      showActions = false;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(
-        element._shouldDisableAction(showActions, robotComment),
-        false
-      );
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', async () => {
-      const projectName = 'foo/bar/baz' as RepoName;
-      const getProjectStub = stubRestApi('getProjectConfig').returns(
-        Promise.resolve({} as ConfigInfo)
-      );
-      element.projectName = projectName;
-      await flush();
-      assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123 as NumericChangeId;
-      element.projectName = 'test project' as RepoName;
-      element.path = 'path/to/file';
-      element.patchNum = 3 as PatchSetNum;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
-      assert.notEqual(
-        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
-          .display,
-        'none'
-      );
-      assert.isTrue(
-        commentStub.calledWithExactly(
-          element.changeNum,
-          element.projectName,
-          'comment_id' as UrlEncodedCommentId
-        )
-      );
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = 3 as PatchSetNum;
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        ''
-      );
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element: GrCommentThread;
-  let addDraftServiceStub: SinonStub;
-  let saveDiffDraftStub: SinonStub;
-  let comment = {
-    id: '7afa4931_de3d65bd',
-    path: '/path/to/file.txt',
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    updated: '2015-12-21 02:01:10.850000000',
-    message: 'Done',
-  };
-  const peanutButterComment = {
-    author: {
-      name: 'Mr. Peanutbutter',
-      email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-    },
-    id: 'baf0414d_60047215' as UrlEncodedCommentId,
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    message: 'is this a crossover episode!?',
-    updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-    path: '/path/to/file.txt',
-    unresolved: true,
-    patch_set: 3 as PatchSetNum,
-  };
-  const mockResponse: Response = {
-    ...new Response(),
-    headers: {} as Headers,
-    redirected: false,
-    status: 200,
-    statusText: '',
-    type: '' as ResponseType,
-    url: '',
-    ok: true,
-    text() {
-      return Promise.resolve(")]}'\n" + JSON.stringify(comment));
-    },
-  };
-  let saveDiffDraftPromiseResolver: (value?: Response) => void;
-  setup(() => {
-    addDraftServiceStub = stubComments('addDraft');
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
-      new Promise<Response>(
-        resolve =>
-          (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
-      )
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [peanutButterComment];
-    flush();
   });
 
-  test('reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    tap(replyBtn);
-    flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.isOk(draft);
-    assert.notOk(draft.message, 'message should be empty');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
+  test('renders with actions unresolved', async () => {
+    element.thread = createThread(c1, {...c2, unresolved: true});
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(`
+        <div id="container">
+          <h3 class="assistive-tech-only">Unresolved Comment thread by Kermit</h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment show-patchset=""></gr-comment>
+            <gr-comment show-patchset=""></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel">
+                Unresolved
+              </span>
+              <div id="actions">
+                <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
+                </iron-icon>
+                <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
+                  Reply
+                </gr-button>
+                <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
+                  Quote
+                </gr-button>
+                <gr-button aria-disabled="false" class="action ack" id="ackBtn" link="" role="button" tabindex="0">
+                  Ack
+                </gr-button>
+                <gr-button aria-disabled="false" class="action done" id="doneBtn" link="" role="button" tabindex="0">
+                  Done
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
   });
 
-  test('quote reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // the quote reply is not autmatically saved so verify that id is not set
-    assert.isNotOk(draft.id);
-    // verify that the draft returned was not saved
-    assert.isNotOk(saveDiffDraftStub.called);
-    assert.equal(draft.message, '> is this a crossover episode!?\n\n');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
+  test('renders with diff', async () => {
+    element.showCommentContext = true;
+    element.thread = createThread(commentWithContext);
+    await element.updateComplete;
+    expect(queryAndAssert(element, '.diff-container')).dom.to.equal(`
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator no-left"
+            id="diff"
+            style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+          >
+          </gr-diff>
+          <div class="view-diff-container">
+            <a href="">
+              <gr-button aria-disabled="false" class="view-diff-button" link="" role="button" tabindex="0">
+                View Diff
+              </gr-button>
+            </a>
+          </div>
+        </div>
+      `);
   });
 
-  test('quote reply multiline', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.comments = [
+  suite('action button clicks', () => {
+    let savePromise: MockPromise<void>;
+    let stub: SinonStub;
+
+    setup(async () => {
+      savePromise = mockPromise<void>();
+      stub = stubComments('saveDraft').returns(savePromise);
+
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+    });
+
+    test('handle Ack', async () => {
+      tap(queryAndAssert(element, '#ackBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Ack');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+      assert.isFalse(element.saving);
+    });
+
+    test('handle Done', async () => {
+      tap(queryAndAssert(element, '#doneBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Done');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+    });
+
+    test('handle Reply', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#replyBtn'));
+      assert.equal(element.unsavedComment?.message, '');
+    });
+
+    test('handle Quote', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#quoteBtn'));
+      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+    });
+  });
+
+  suite('self removal when empty thread changed to editing:false', () => {
+    let threadEl: GrCommentThread;
+
+    setup(async () => {
+      threadEl = basicFixture.instantiate();
+      threadEl.thread = createThread();
+    });
+
+    test('new thread el normally has a parent and an unsaved comment', async () => {
+      await waitUntil(() => threadEl.editing);
+      assert.isOk(threadEl.unsavedComment);
+      assert.isOk(threadEl.parentElement);
+    });
+
+    test('thread el removed after clicking CANCEL', async () => {
+      await waitUntil(() => threadEl.editing);
+
+      const commentEl = queryAndAssert(threadEl, 'gr-comment');
+      const buttonEl = queryAndAssert(commentEl, 'gr-button.cancel');
+      tap(buttonEl);
+
+      await waitUntil(() => !threadEl.editing);
+      assert.isNotOk(threadEl.parentElement);
+    });
+  });
+
+  test('comments are sorted correctly', () => {
+    const comments: CommentInfo[] = [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        path: 'test',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
       },
     ];
-    flush();
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(
-      draft.message,
-      '> is this a crossover episode!?\n> It might be!\n\n'
-    );
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Ack',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    assert.isOk(ackBtn);
-    tap(ackBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(draft.message, 'Ack');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('done', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Done',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.isFalse(saveDiffDraftStub.called);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.isOk(doneBtn);
-    tap(doneBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // Since the reply is automatically saved, verify that draft.id is set in
-    // the model
-    assert.equal(draft.id, '7afa4931_de3d65bd');
-    assert.equal(draft.message, 'Done');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-    assert.isTrue(saveDiffDraftStub.called);
-  });
-
-  test('save', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
-    await flush();
-    assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-  });
-
-  test('please fix', async () => {
-    comment = peanutButterComment;
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-    const promise = mockPromise();
-    commentEl!.addEventListener('create-fix-comment', async () => {
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isFalse(addDraftServiceStub.called);
-      saveDiffDraftPromiseResolver(mockResponse);
-      // flushing so the saveDiffDraftStub resolves and the draft is returned
-      await flush();
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isTrue(addDraftServiceStub.called);
-      const draft = saveDiffDraftStub.firstCall.args[2];
-      assert.equal(
-        draft.message,
-        '> is this a crossover episode!?\n\nPlease fix.'
-      );
-      assert.equal(
-        draft.in_reply_to,
-        'baf0414d_60047215' as UrlEncodedCommentId
-      );
-      assert.isTrue(draft.unresolved);
-      promise.resolve();
-    });
-    assert.isFalse(saveDiffDraftStub.called);
-    assert.isFalse(addDraftServiceStub.called);
-    commentEl!.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        detail: {comment: commentEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
-    await promise;
-  });
-
-  test('discard', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    assert.isOk(element.comments[0]);
-    const deleteDraftStub = stubComments('deleteDraft');
-    element.push(
-      'comments',
-      element._newReply(
-        element.comments[0]!.id as UrlEncodedCommentId,
-        'it’s pronouced jiff, not giff'
-      )
-    );
-    await flush();
-
-    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl?._fireSave(); // tell the model about the draft
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-  });
-
-  test('discard with a single comment still fires event with previous rootId', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    element.comments = [];
-    element.addOrEditDraft(1 as LineNumber);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    const rootId = element.rootId;
-    assert.isOk(rootId);
-    flush();
-    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
-    assert.ok(draftEl);
-    const deleteDraftStub = stubComments('deleteDraft');
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-    assert.isTrue(deleteDraftStub.called);
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    assert.isOk(commentEl);
-    commentEl!.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: {comment: updatedComment},
-        composed: true,
-        bubbles: true,
-      })
-    );
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-          unresolved: false,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-      ];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
-      pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      pressAndReleaseKeyOn(element, 69, 'shift', 'E');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(
-        element._orderedComments[4].in_reply_to,
-        'jacks_reply' as UrlEncodedCommentId
-      );
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    let draft;
-    element.comments = [];
-    element.path = 'abcd';
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 3 as PatchSetNum);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.diffSide = Side.RIGHT;
-    element.patchNum = 2 as PatchSetNum;
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 2 as PatchSetNum);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.path = 'abcd';
-    element.addOrEditDraft(1);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = (element.comments[0] as UIDraft)
-      .__draftID as UrlEncodedCommentId;
-    delete (element.comments[0] as UIDraft).__draft;
-    element.addOrEditDraft(1);
-    assert.equal(addDraftServiceStub.callCount, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
-    assert.isOk(label);
-    assert.isFalse(label!.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(label!.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [
+    const results = sortComments(comments);
+    assert.deepEqual(results, [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '2' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        __draft: true,
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
       },
       {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '1' as UrlEncodedCommentId,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
       },
       {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '3' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
-        __draft: true,
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
       },
-    ];
-    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.diffSide = Side.LEFT;
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('diff-side'), Side.LEFT);
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.isOk(element.getAttribute('range'));
-    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    });
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element: GrCommentThread;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('saveDiffDraft').returns(
-      Promise.resolve({
-        ...new Response(),
-        ok: true,
-        text() {
-          return Promise.resolve(
-            ")]}'\n" +
-              JSON.stringify({
-                id: '7afa4931_de3d65bd',
-                path: '/path/to/file.txt',
-                line: 5,
-                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-                updated: '2015-12-21 02:01:10.850000000',
-                message: 'Done',
-              })
-          );
-        },
-      })
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        path: '/path/to/file.txt',
-        unresolved: false,
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
       },
-    ];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+    ]);
   });
 });
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 d1ddd31..1411c88 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -27,53 +27,50 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
-  BasePatchSetNum,
-  ConfigInfo,
+  CommentLinks,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
+  RobotCommentInfo,
 } from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
-  isDraft,
+  Comment,
+  isDraftOrUnsaved,
   isRobot,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  isUnsaved,
 } from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
+import {
+  OpenFixPreviewEventDetail,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide} from '../../../constants/constants';
+import {getRandomInt} from '../../../utils/math-util';
+import {Subject} from 'rxjs';
+import {debounceTime} from 'rxjs/operators';
 
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
 const FILE = 'FILE';
 
+// visible for testing
+export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+
 export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
 
 /**
@@ -88,25 +85,21 @@
   'When disagreeing, explain the advantage of your approach.',
 ];
 
-interface CommentOverlays {
-  confirmDelete?: GrOverlay | null;
-  confirmDiscard?: GrOverlay | null;
+declare global {
+  interface HTMLElementEventMap {
+    'comment-editing-changed': CustomEvent<boolean>;
+    'comment-unresolved-changed': CustomEvent<boolean>;
+    'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+  }
 }
 
-export interface GrComment {
-  $: {
-    container: HTMLDivElement;
-    resolvedCheckbox: HTMLInputElement;
-    header: HTMLDivElement;
-  };
+export interface CommentAnchorTapEventDetail {
+  number: LineNumber;
+  side?: CommentSide;
 }
 
 @customElement('gr-comment')
-export class GrComment extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrComment extends LitElement {
   /**
    * Fired when the create fix comment action is triggered.
    *
@@ -120,30 +113,6 @@
    */
 
   /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * Fired when this comment is edited.
-   *
-   * @event comment-edit
-   */
-
-  /**
-   * Fired when this comment is saved.
-   *
-   * @event comment-save
-   */
-
-  /**
-   * Fired when this comment is updated.
-   *
-   * @event comment-update
-   */
-
-  /**
    * Fired when editing status changed.
    *
    * @event comment-editing-changed
@@ -155,124 +124,102 @@
    * @event comment-anchor-tap
    */
 
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @query('#editTextarea')
+  textarea?: GrTextarea;
 
-  @property({type: String})
-  projectName?: RepoName;
+  @query('#container')
+  container?: HTMLElement;
 
-  @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment;
+  @query('#resolvedCheckbox')
+  resolvedCheckbox?: HTMLInputElement;
 
+  @query('#confirmDeleteOverlay')
+  confirmDeleteOverlay?: GrOverlay;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property. This is only used for hasHumanReply at the moment.
   @property({type: Array})
-  comments?: UIComment[];
-
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
-
-  @property({type: Boolean, observer: '_draftChanged'})
-  draft = false;
-
-  @property({type: Boolean, observer: '_editingChanged'})
-  editing = false;
-
-  // Assigns a css property to the comment hiding the comment while it's being
-  // discarded
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-  })
-  discarding = false;
-
-  @property({type: Boolean})
-  hasChildren?: boolean;
-
-  @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: Boolean})
-  showActions?: boolean;
-
-  @property({type: Boolean})
-  _showHumanActions?: boolean;
-
-  @property({type: Boolean})
-  _showRobotActions?: boolean;
-
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    observer: '_toggleCollapseClass',
-  })
-  collapsed = true;
-
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
-  @property({type: Boolean})
-  robotButtonDisabled = false;
-
-  @property({type: Boolean})
-  _hasHumanReply?: boolean;
-
-  @property({type: Boolean})
-  _isAdmin = false;
-
-  @property({type: Object})
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  _xhrPromise?: Promise<any>; // Used for testing.
-
-  @property({type: String, observer: '_messageTextChanged'})
-  _messageText = '';
-
-  @property({type: String})
-  side?: string;
-
-  @property({type: Boolean})
-  resolved = false;
-
-  // Intentional to share the object across instances.
-  @property({type: Object})
-  _numPendingDraftRequests: {number: number} = {number: 0};
-
-  @property({type: Boolean})
-  _enableOverlay = false;
+  comments?: Comment[];
 
   /**
-   * Property for storing references to overlay elements. When the overlays
-   * are moved to getRootElement() to be shown they are no-longer
-   * children, so they can't be queried along the tree, so they are stored
-   * here.
+   * Initial collapsed state of the comment.
    */
-  @property({type: Object})
-  _overlays: CommentOverlays = {};
+  @property({type: Boolean, attribute: 'initially-collapsed'})
+  initiallyCollapsed?: boolean;
+
+  /**
+   * This is the *current* (internal) collapsed state of the comment. Do not set
+   * from the outside. Use `initiallyCollapsed` instead. This is just a
+   * reflected property such that css rules can be based on it.
+   */
+  @property({type: Boolean, reflect: true})
+  collapsed?: boolean;
+
+  @property({type: Boolean, attribute: 'robot-button-disabled'})
+  robotButtonDisabled = false;
+
+  /* internal only, but used in css rules */
+  @property({type: Boolean, reflect: true})
+  saving = false;
+
+  /**
+   * `saving` and `autoSaving` are separate and cannot be set at the same time.
+   * `saving` affects the UI state (disabled buttons, etc.) and eventually
+   * leaves editing mode, but `autoSaving` just happens in the background
+   * without the user noticing.
+   */
+  @state()
+  autoSaving?: Promise<void>;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  editing = false;
+
+  @state()
+  commentLinks: CommentLinks = {};
+
+  @state()
+  repoName?: RepoName;
+
+  /* The 'dirty' state of the comment.message, which will be saved on demand. */
+  @state()
+  messageText = '';
+
+  /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+  @state()
+  unresolved = true;
 
   @property({type: Boolean})
-  _showRespectfulTip = false;
+  showConfirmDeleteOverlay = false;
 
   @property({type: Boolean})
-  showPatchset = true;
+  showRespectfulTip = false;
 
   @property({type: String})
-  _respectfulReviewTip?: string;
+  respectfulReviewTip?: string;
 
   @property({type: Boolean})
-  _respectfulTipDismissed = false;
+  respectfulTipDismissed = false;
 
   @property({type: Boolean})
-  _unableToSave = false;
+  unableToSave = false;
 
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
+  @property({type: Boolean, attribute: 'show-patchset'})
+  showPatchset = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-ported-comment'})
   showPortedComment = false;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  isAdmin = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -280,67 +227,700 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly commentsService = getAppContext().commentsService;
+  private readonly changeModel = getAppContext().changeModel;
 
-  private fireUpdateTask?: DelayedTask;
+  private readonly commentsModel = getAppContext().commentsModel;
 
-  private storeTask?: DelayedTask;
+  private readonly userModel = getAppContext().userModel;
 
-  private draftToastTask?: DelayedTask;
+  private readonly configModel = getAppContext().configModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      this.collapsed = !!this.comment.collapsed;
-    }
-    this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+  private readonly shortcuts = new ShortcutController(this);
+
+  /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autoSave().
+   */
+  private autoSaveTrigger$ = new Subject();
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalMessage = '';
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalUnresolved = false;
+
+  constructor() {
+    super();
+    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
+    subscribe(
+      this,
+      this.configModel.repoCommentLinks$,
+      x => (this.commentLinks = x)
     );
+    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    subscribe(
+      this,
+      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () => {
+        this.autoSave();
+      }
+    );
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
     for (const key of ['s', Key.ENTER]) {
       for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
-        addShortcut(this, {key, modifiers: [modifier]}, e =>
-          this._handleSaveKey(e)
-        );
+        this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+          this.save();
+        });
       }
     }
   }
 
   override disconnectedCallback() {
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-    this.fireUpdateTask?.cancel();
-    this.storeTask?.cancel();
-    this.draftToastTask?.cancel();
-    if (this.textarea) {
-      this.textarea.closeDropdown();
-    }
+    // Clean up emoji dropdown.
+    if (this.textarea) this.textarea.closeDropdown();
     super.disconnectedCallback();
   }
 
-  /** 2nd argument is for *triggering* the computation only. */
-  _getAuthor(comment?: UIComment, _?: unknown) {
-    return comment?.author || this._selfAccount;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+          padding: var(--spacing-m);
+        }
+        :host([collapsed]) {
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        :host([saving]) {
+          pointer-events: none;
+        }
+        :host([saving]) .actions,
+        :host([saving]) .robotActions,
+        :host([saving]) .date {
+          opacity: 0.5;
+        }
+        .body {
+          padding-top: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          cursor: pointer;
+          display: flex;
+        }
+        .headerLeft > span {
+          font-weight: var(--font-weight-bold);
+        }
+        .headerMiddle {
+          color: var(--deemphasized-text-color);
+          flex: 1;
+          overflow: hidden;
+        }
+        .draftLabel,
+        .draftTooltip {
+          color: var(--deemphasized-text-color);
+          display: inline;
+        }
+        .date {
+          justify-content: flex-end;
+          text-align: right;
+          white-space: nowrap;
+        }
+        span.date {
+          color: var(--deemphasized-text-color);
+        }
+        span.date:hover {
+          text-decoration: underline;
+        }
+        .actions,
+        .robotActions {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: 0;
+        }
+        .robotActions {
+          /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+          margin: 4px 0 -4px;
+        }
+        .action {
+          margin-left: var(--spacing-l);
+        }
+        .rightActions {
+          display: flex;
+          justify-content: flex-end;
+        }
+        .rightActions gr-button {
+          --gr-button-padding: 0 var(--spacing-s);
+        }
+        .editMessage {
+          display: block;
+          margin: var(--spacing-m) 0;
+          width: 100%;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+        }
+        .robotId {
+          color: var(--deemphasized-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        .robotRun {
+          margin-left: var(--spacing-m);
+        }
+        .robotRunLink {
+          margin-left: var(--spacing-m);
+        }
+        /* just for a11y */
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+        }
+        label.show-hide iron-icon {
+          vertical-align: top;
+        }
+        :host([collapsed]) #container .body {
+          padding-top: 0;
+        }
+        #container .collapsedContent {
+          display: block;
+          overflow: hidden;
+          padding-left: var(--spacing-m);
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .resolve,
+        .unresolved {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          margin: 0;
+        }
+        .resolve label {
+          color: var(--comment-text-color);
+        }
+        gr-dialog .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        #deleteBtn {
+          --gr-button-text-color: var(--deemphasized-text-color);
+          --gr-button-padding: 0;
+        }
+
+        /** Disable select for the caret and actions */
+        .actions,
+        .show-hide {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .respectfulReviewTip {
+          justify-content: space-between;
+          display: flex;
+          padding: var(--spacing-m);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin-bottom: var(--spacing-m);
+        }
+        .respectfulReviewTip div {
+          display: flex;
+        }
+        .respectfulReviewTip div iron-icon {
+          margin-right: var(--spacing-s);
+        }
+        .respectfulReviewTip a {
+          white-space: nowrap;
+          margin-right: var(--spacing-s);
+          padding-left: var(--spacing-m);
+          text-decoration: none;
+        }
+        .pointer {
+          cursor: pointer;
+        }
+        .patchset-text {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-s);
+        }
+        .headerLeft gr-account-label {
+          --account-max-length: 130px;
+          width: 150px;
+        }
+        .headerLeft gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        .draft gr-account-label {
+          width: unset;
+        }
+        .portedMessage {
+          margin: 0 var(--spacing-m);
+        }
+        .link-icon {
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  _getUrlForComment(comment?: UIComment) {
-    if (!comment || !this.changeNum || !this.projectName) return '';
+  override render() {
+    if (isUnsaved(this.comment) && !this.editing) return;
+    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <div id="container" class="${classMap(classes)}">
+        <div
+          class="header"
+          id="header"
+          @click="${() => (this.collapsed = !this.collapsed)}"
+        >
+          <div class="headerLeft">
+            ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+            ${this.renderDraftLabel()}
+          </div>
+          <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+          ${this.renderRunDetails()} ${this.renderDeleteButton()}
+          ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+        </div>
+        <div class="body">
+          ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+          ${this.renderRespectfulTip()} ${this.renderCommentMessage()}
+          ${this.renderHumanActions()} ${this.renderRobotActions()}
+        </div>
+      </div>
+      ${this.renderConfirmDialog()}
+    `;
+  }
+
+  private renderAuthor() {
+    if (isRobot(this.comment)) {
+      const id = this.comment.robot_id;
+      return html`<span class="robotName">${id}</span>`;
+    }
+    const classes = {draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <gr-account-label
+        .account="${this.comment?.author ?? this.account}"
+        class="${classMap(classes)}"
+        hideStatus
+      >
+      </gr-account-label>
+    `;
+  }
+
+  private renderPortedCommentMessage() {
+    if (!this.showPortedComment) return;
+    if (!this.comment?.patch_set) return;
+    return html`
+      <a href="${this.getUrlForComment()}">
+        <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+          From patchset ${this.comment?.patch_set}]]
+        </span>
+      </a>
+    `;
+  }
+
+  private renderDraftLabel() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    let label = 'DRAFT';
+    let tooltip =
+      'This draft is only visible to you. ' +
+      "To publish drafts, click the 'Reply' or 'Start review' button " +
+      "at the top of the change or press the 'a' key.";
+    if (this.unableToSave) {
+      label += ' (Failed to save)';
+      tooltip = 'Unable to save draft. Please try to save again.';
+    }
+    return html`
+      <gr-tooltip-content
+        class="draftTooltip"
+        has-tooltip
+        title="${tooltip}"
+        max-width="20em"
+        show-icon
+      >
+        <span class="draftLabel">${label}</span>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderCollapsedContent() {
+    if (!this.collapsed) return;
+    return html`
+      <span class="collapsedContent">${this.comment?.message}</span>
+    `;
+  }
+
+  private renderRunDetails() {
+    if (!isRobot(this.comment)) return;
+    if (!this.comment?.url || this.collapsed) return;
+    return html`
+      <div class="runIdMessage message">
+        <div class="runIdInformation">
+          <a class="robotRunLink" href="${this.comment.url}">
+            <span class="robotRun link">Run Details</span>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Deleting a comment is an admin feature. It means more than just discarding
+   * a draft. It is an action applied to published comments.
+   */
+  private renderDeleteButton() {
+    if (
+      !this.isAdmin ||
+      isDraftOrUnsaved(this.comment) ||
+      isRobot(this.comment)
+    )
+      return;
+    if (this.collapsed) return;
+    return html`
+      <gr-button
+        id="deleteBtn"
+        title="Delete Comment"
+        link
+        class="action delete"
+        @click="${this.openDeleteCommentOverlay}"
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+    `;
+  }
+
+  private renderPatchset() {
+    if (!this.showPatchset) return;
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return html`
+      <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+    `;
+  }
+
+  private renderDate() {
+    if (!this.comment?.updated || this.collapsed) return;
+    return html`
+      <span class="separator"></span>
+      <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+        <gr-date-formatter
+          withTooltip
+          .dateStr="${this.comment.updated}"
+        ></gr-date-formatter>
+      </span>
+    `;
+  }
+
+  private renderToggle() {
+    const icon = this.collapsed
+      ? 'gr-icons:expand-more'
+      : 'gr-icons:expand-less';
+    const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+    return html`
+      <div class="show-hide" tabindex="0">
+        <label class="show-hide" aria-label="${ariaLabel}">
+          <input
+            type="checkbox"
+            class="show-hide"
+            ?checked="${this.collapsed}"
+            @change="${() => (this.collapsed = !this.collapsed)}"
+          />
+          <iron-icon id="icon" icon="${icon}"></iron-icon>
+        </label>
+      </div>
+    `;
+  }
+
+  private renderRobotAuthor() {
+    if (!isRobot(this.comment) || this.collapsed) return;
+    return html`<div class="robotId">${this.comment.author?.name}</div>`;
+  }
+
+  private renderEditingTextarea() {
+    if (!this.editing || this.collapsed) return;
+    return html`
+      <gr-textarea
+        id="editTextarea"
+        class="editMessage"
+        autocomplete="on"
+        code=""
+        ?disabled="${this.saving}"
+        rows="4"
+        text="${this.messageText}"
+        @text-changed="${(e: ValueChangedEvent) => {
+          // TODO: This is causing a re-render of <gr-comment> on every key
+          // press. Try to avoid always setting `this.messageText` or at least
+          // debounce it. Most of the code can just inspect the current value
+          // of the textare instead of needing a dedicated property.
+          this.messageText = e.detail.value;
+          this.autoSaveTrigger$.next();
+        }}"
+      ></gr-textarea>
+    `;
+  }
+
+  private renderRespectfulTip() {
+    if (!this.showRespectfulTip || this.respectfulTipDismissed) return;
+    if (this.collapsed) return;
+    return html`
+      <div class="respectfulReviewTip">
+        <div>
+          <gr-tooltip-content
+            has-tooltip
+            title="Tips for respectful code reviews."
+          >
+            <iron-icon
+              class="pointer"
+              icon="gr-icons:lightbulb-outline"
+            ></iron-icon>
+          </gr-tooltip-content>
+          ${this.respectfulReviewTip}
+        </div>
+        <div>
+          <a
+            tabindex="-1"
+            @click="${this.onRespectfulReadMoreClick}"
+            href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+            target="_blank"
+          >
+            Read more
+          </a>
+          <a
+            tabindex="-1"
+            class="close pointer"
+            @click="${this.dismissRespectfulTip}"
+          >
+            Not helpful
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderCommentMessage() {
+    if (this.collapsed || this.editing) return;
+    return html`
+      <!--The "message" class is needed to ensure selectability from
+          gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        .content="${this.comment?.message}"
+        .config="${this.commentLinks}"
+        ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+      ></gr-formatted-text>
+    `;
+  }
+
+  private renderCopyLinkIcon() {
+    // Only show the icon when the thread contains a published comment.
+    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <iron-icon
+        class="copy link-icon"
+        @click="${this.handleCopyLink}"
+        title="Copy link to this comment"
+        icon="gr-icons:link"
+        role="button"
+        tabindex="0"
+      >
+      </iron-icon>
+    `;
+  }
+
+  private renderHumanActions() {
+    if (!this.account || isRobot(this.comment)) return;
+    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="actions">
+        <div class="action resolve">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              ?checked="${!this.unresolved}"
+              @change="${this.handleToggleResolved}"
+            />
+            Resolved
+          </label>
+        </div>
+        ${this.renderDraftActions()}
+      </div>
+    `;
+  }
+
+  private renderDraftActions() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="rightActions">
+        ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
+        ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
+        ${this.renderEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()}
+      </div>
+    `;
+  }
+
+  private renderDiscardButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled="${this.saving}"
+      class="action discard"
+      @click="${this.discard}"
+      >Discard</gr-button
+    >`;
+  }
+
+  private renderEditButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled="${this.saving}"
+      class="action edit"
+      @click="${this.edit}"
+      >Edit</gr-button
+    >`;
+  }
+
+  private renderCancelButton() {
+    if (!this.editing) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.saving}"
+        class="action cancel"
+        @click="${this.cancel}"
+        >Cancel</gr-button
+      >
+    `;
+  }
+
+  private renderSaveButton() {
+    if (!this.editing && !this.unableToSave) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.isSaveDisabled()}"
+        class="action save"
+        @click="${this.save}"
+        >Save</gr-button
+      >
+    `;
+  }
+
+  private renderRobotActions() {
+    if (!this.account || !isRobot(this.comment)) return;
+    const endpoint = html`
+      <gr-endpoint-decorator name="robot-comment-controls">
+        <gr-endpoint-param name="comment" .value="${this.comment}">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+    return html`
+      <div class="robotActions">
+        ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+        ${this.renderPleaseFixButton()}
+      </div>
+    `;
+  }
+
+  private renderShowFixButton() {
+    if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled="${this.saving}"
+        @click="${this.handleShowFix}"
+      >
+        Show Fix
+      </gr-button>
+    `;
+  }
+
+  private renderPleaseFixButton() {
+    if (this.hasHumanReply()) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.robotButtonDisabled}"
+        class="action fix"
+        @click="${this.handleFix}"
+      >
+        Please Fix
+      </gr-button>
+    `;
+  }
+
+  private renderConfirmDialog() {
+    if (!this.showConfirmDeleteOverlay) return;
+    return html`
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+        <gr-confirm-delete-comment-dialog
+          id="confirmDeleteComment"
+          @confirm="${this.handleConfirmDeleteComment}"
+          @cancel="${this.closeDeleteCommentOverlay}"
+        >
+        </gr-confirm-delete-comment-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private getUrlForComment() {
+    const comment = this.comment;
+    if (!comment || !this.changeNum || !this.repoName) return '';
     if (!comment.id) throw new Error('comment must have an id');
     return GerritNav.getUrlForComment(
       this.changeNum as NumericChangeId,
-      this.projectName,
+      this.repoName,
       comment.id
     );
   }
 
-  _handlePortedMessageClick() {
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    assertIsDefined(this.comment, 'comment');
+    this.unresolved = this.comment.unresolved ?? true;
+    if (isUnsaved(this.comment)) this.editing = true;
+    if (isDraftOrUnsaved(this.comment)) {
+      this.collapsed = false;
+    } else {
+      this.collapsed = !!this.initiallyCollapsed;
+    }
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('editing')) {
+      this.onEditingChanged();
+    }
+    if (changed.has('unresolved')) {
+      // The <gr-comment-thread> component wants to change its color based on
+      // the (dirty) unresolved state, so let's notify it about changes.
+      fire(this, 'comment-unresolved-changed', this.unresolved);
+    }
+  }
+
+  private handlePortedMessageClick() {
     assertIsDefined(this.comment, 'comment');
     this.reporting.reportInteraction('navigate-to-original-comment', {
       line: this.comment.line,
@@ -348,755 +928,245 @@
     });
   }
 
-  @observe('editing')
-  _onEditingChange(editing?: boolean) {
-    this.dispatchEvent(
-      new CustomEvent('comment-editing-changed', {
-        detail: !!editing,
-        bubbles: true,
-        composed: true,
-      })
+  // private, but visible for testing
+  getRandomInt(from: number, to: number) {
+    return getRandomInt(from, to);
+  }
+
+  private dismissRespectfulTip() {
+    this.respectfulTipDismissed = true;
+    this.reporting.reportInteraction('respectful-tip-dismissed', {
+      tip: this.respectfulReviewTip,
+    });
+    // add a 14-day delay to the tip cache
+    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+  }
+
+  private onRespectfulReadMoreClick() {
+    this.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  private handleCopyLink() {
+    fireEvent(this, 'copy-comment-link');
+  }
+
+  /** Enter editing mode. */
+  private edit() {
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('Cannot edit published comment.');
+    }
+    if (this.editing) return;
+    this.editing = true;
+  }
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property.
+  private hasHumanReply() {
+    if (!this.comment || !this.comments) return false;
+    return this.comments.some(
+      c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
     );
-    if (!editing) return;
+  }
+
+  // private, but visible for testing
+  getEventPayload(): OpenFixPreviewEventDetail {
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return {comment: this.comment, patchNum: this.comment.patch_set};
+  }
+
+  private onEditingChanged() {
+    if (this.editing) {
+      this.collapsed = false;
+      this.messageText = this.comment?.message ?? '';
+      this.unresolved = this.comment?.unresolved ?? true;
+      this.originalMessage = this.messageText;
+      this.originalUnresolved = this.unresolved;
+      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+    }
+    this.setRespectfulTip();
+
+    // Parent components such as the reply dialog might be interested in whether
+    // come of their child components are in editing mode.
+    fire(this, 'comment-editing-changed', this.editing);
+  }
+
+  private setRespectfulTip() {
     // visibility based on cache this will make sure we only and always show
     // a tip once every Math.max(a day, period between creating comments)
     const cachedVisibilityOfRespectfulTip =
       this.storage.getRespectfulTipVisibility();
-    if (!cachedVisibilityOfRespectfulTip) {
-      // we still want to show the tip with a probability of 30%
-      if (this.getRandomNum(0, 3) >= 1) return;
-      this._showRespectfulTip = true;
-      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
-      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+    if (this.editing && !cachedVisibilityOfRespectfulTip) {
+      // we still want to show the tip with a probability of 33%
+      if (this.getRandomInt(0, 2) >= 1) return;
+      this.showRespectfulTip = true;
+      const randomIdx = this.getRandomInt(0, RESPECTFUL_REVIEW_TIPS.length);
+      this.respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
       this.reporting.reportInteraction('respectful-tip-appeared', {
-        tip: this._respectfulReviewTip,
+        tip: this.respectfulReviewTip,
       });
       // update cache
       this.storage.setRespectfulTipVisibility();
     }
   }
 
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min: number, max: number) {
-    return Math.floor(Math.random() * (max - min) + min);
+  // private, but visible for testing
+  isSaveDisabled() {
+    assertIsDefined(this.comment, 'comment');
+    if (this.saving) return true;
+    if (this.comment.unresolved !== this.unresolved) return false;
+    return !this.messageText?.trimEnd();
   }
 
-  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
-    return showTip && !tipDismissed;
+  private handleEsc() {
+    // vim users don't like ESC to cancel/discard, so only do this when the
+    // comment text is empty.
+    if (!this.messageText?.trimEnd()) this.cancel();
   }
 
-  _dismissRespectfulTip() {
-    this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction('respectful-tip-dismissed', {
-      tip: this._respectfulReviewTip,
+  private handleAnchorClick() {
+    assertIsDefined(this.comment, 'comment');
+    fire(this, 'comment-anchor-tap', {
+      number: this.comment.line || FILE,
+      side: this.comment?.side,
     });
-    // add a 14-day delay to the tip cache
-    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
   }
 
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
+  private handleFix() {
+    // Handled by <gr-comment-thread>.
+    fire(this, 'create-fix-comment', this.getEventPayload());
   }
 
-  get textarea(): GrTextarea | null {
-    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+  private handleShowFix() {
+    // Handled top-level in the diff and change view components.
+    fire(this, 'open-fix-preview', this.getEventPayload());
   }
 
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
-        '#confirmDeleteOverlay'
-      ) as GrOverlay | null;
+  // private, but visible for testing
+  cancel() {
+    assertIsDefined(this.comment, 'comment');
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('only unsaved and draft comments are editable');
     }
-    return this._overlays.confirmDelete;
+    this.messageText = this.originalMessage;
+    this.unresolved = this.originalUnresolved;
+    this.save();
   }
 
-  get confirmDiscardOverlay() {
-    if (!this._overlays.confirmDiscard) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
-        '#confirmDiscardOverlay'
-      ) as GrOverlay | null;
+  async autoSave() {
+    if (this.saving || this.autoSaving) return;
+    if (!this.editing || !this.comment) return;
+    if (!isDraftOrUnsaved(this.comment)) return;
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') return;
+    if (messageToSave === this.comment.message) return;
+
+    try {
+      this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+      await this.autoSaving;
+    } finally {
+      this.autoSaving = undefined;
     }
-    return this._overlays.confirmDiscard;
   }
 
-  _computeShowHideIcon(collapsed: boolean) {
-    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+  async discard() {
+    this.messageText = '';
+    await this.save();
   }
 
-  _computeShowHideAriaLabel(collapsed: boolean) {
-    return collapsed ? 'Expand' : 'Collapse';
-  }
+  async save() {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
 
-  @observe('showActions', 'isRobotComment')
-  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
-    // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].includes(undefined)) {
-      return;
-    }
-
-    this._showHumanActions = showActions && !isRobotComment;
-    this._showRobotActions = showActions && isRobotComment;
-  }
-
-  hasPublishedComment(comments?: UIComment[]) {
-    if (!comments?.length) return false;
-    return comments.length > 1 || !isDraft(comments[0]);
-  }
-
-  @observe('comment')
-  _isRobotComment(comment: UIRobot) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    return this.restApiService.getIsAdmin();
-  }
-
-  _computeDraftTooltip(unableToSave: boolean) {
-    return unableToSave
-      ? 'Unable to save draft. Please try to save again.'
-      : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
-          "or 'Start review' button at the top of the change or press the 'A' key.";
-  }
-
-  _computeDraftText(unableToSave: boolean) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  handleCopyLink() {
-    fireEvent(this, 'copy-comment-link');
-  }
-
-  save(opt_comment?: UIComment) {
-    let comment = opt_comment;
-    if (!comment) {
-      comment = this.comment;
-    }
-
-    this.set('comment.message', this._messageText);
-    this.editing = false;
-    this.disabled = true;
-
-    if (!this._messageText) {
-      return this._discardDraft();
-    }
-
-    const details = this.commentDetailsForReporting();
-    this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
-    this._xhrPromise = this._saveDraft(comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          return;
+    try {
+      this.saving = true;
+      this.unableToSave = false;
+      if (this.autoSaving) await this.autoSaving;
+      // Depending on whether `messageToSave` is empty we treat this either as
+      // a discard or a save action.
+      const messageToSave = this.messageText.trimEnd();
+      if (messageToSave === '') {
+        // Don't try to discard UnsavedInfo. Nothing to do then.
+        if (this.comment.id) {
+          await this.commentsModel.discardDraft(this.comment.id);
         }
-
-        this._eraseDraftCommentFromStorage();
-        return this.restApiService.getResponseObject(response).then(obj => {
-          const resComment = obj as unknown as UIDraft;
-          if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment?.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
-          this.comment = resComment;
-          const details = this.commentDetailsForReporting();
-          this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
-          this._fireSave();
-          return obj;
-        });
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  private commentDetailsForReporting() {
-    return {
-      id: this.comment?.id,
-      message_length: this.comment?.message?.length,
-      in_reply_to: this.comment?.in_reply_to,
-      unresolved: this.comment?.unresolved,
-      path_length: this.comment?.path?.length,
-      line: this.comment?.range?.start_line ?? this.comment?.line,
-    };
-  }
-
-  _eraseDraftCommentFromStorage() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.storeTask?.cancel();
-
-    assertIsDefined(this.comment?.path, 'comment.path');
-    assertIsDefined(this.changeNum, 'changeNum');
-    this.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
-    });
-  }
-
-  _commentChanged(comment: UIComment) {
-    this.editing = isDraft(comment) && !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    this.discarding = false;
-    if (this.editing) {
-      // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  @observe('comment', 'comments.*')
-  _computeHasHumanReply() {
-    const comment = this.comment;
-    if (!comment || !this.comments) return;
-    // hide please fix button for robot comment that has human reply
-    this._hasHumanReply = this.comments.some(
-      c =>
-        c.in_reply_to &&
-        c.in_reply_to === comment.id &&
-        !(c as UIRobot).robot_id
-    );
-  }
-
-  _getEventPayload(): OpenFixPreviewEventDetail {
-    return {comment: this.comment, patchNum: this.patchNum};
-  }
-
-  _fireEdit() {
-    if (this.comment) this.commentsService.editDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-edit', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireSave() {
-    if (this.comment) this.commentsService.addDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-save', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireUpdate() {
-    this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
-      this.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: this._getEventPayload(),
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  }
-
-  _computeAccountLabelClass(draft: boolean) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft: boolean) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing?: boolean, previousValue?: boolean) {
-    // Polymer 2: observer fires when at least one property is defined.
-    // Do nothing to prevent comment.__editing being overwritten
-    // if previousValue is undefined
-    if (previousValue === undefined) return;
-
-    this.$.container.classList.toggle('editing', editing);
-    if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot?.querySelector(
-        '.cancel'
-      ) as GrButton | null;
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
+      } else {
+        // No need to make a backend call when nothing has changed.
+        if (
+          messageToSave !== this.comment?.message ||
+          this.unresolved !== this.comment.unresolved
+        ) {
+          await this.rawSave(messageToSave, {showToast: true});
+        }
       }
-    }
-    if (isDraft(this.comment)) {
-      this.comment.__editing = this.editing;
-    }
-    if (!!editing !== !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      setTimeout(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(
-    draft: string,
-    comment: UIComment | undefined,
-    resolved?: boolean
-  ) {
-    // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || (comment.unresolved === resolved && draft)) {
-      return false;
-    }
-    return !draft || draft.trim() === '';
-  }
-
-  _handleSaveKey(e: Event) {
-    if (
-      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
-    ) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc(e: Event) {
-    if (!this._messageText.length) {
-      e.preventDefault();
-      this._handleCancel(e);
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed: boolean) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  @observe('comment.message')
-  _commentMessageChanged(message: string) {
-    /*
-     * Only overwrite the message text user has typed if there is no existing
-     * text typed by the user. This prevents the bug where creating another
-     * comment triggered a recomputation of comments and the text written by
-     * the user was lost.
-     */
-    if (!this._messageText || !this.editing) this._messageText = message || '';
-  }
-
-  _messageTextChanged(_: string, oldValue: string) {
-    // Only store comments that are being edited in local storage.
-    if (
-      !this.comment ||
-      (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
-    ) {
-      return;
-    }
-
-    const patchNum = this.comment.patch_set
-      ? this.comment.patch_set
-      : this._getPatchNum();
-    const {path, line, range} = this.comment;
-    if (!path) return;
-    this.storeTask = debounce(
-      this.storeTask,
-      () => {
-        const message = this._messageText;
-        if (this.changeNum === undefined) {
-          throw new Error('undefined changeNum');
-        }
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum,
-          path,
-          line,
-          range,
-        };
-
-        if ((!message || !message.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.storage.setDraftComment(commentLocation, message);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _handleAnchorClick(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    this.dispatchEvent(
-      new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      })
-    );
-  }
-
-  _handleEdit(e: Event) {
-    e.preventDefault();
-    if (this.comment?.message) this._messageText = this.comment.message;
-    this.editing = true;
-    this._fireEdit();
-    this.reporting.recordDraftInteraction();
-  }
-
-  _handleSave(e: Event) {
-    e.preventDefault();
-
-    // Ignore saves started while already saving.
-    if (this.disabled) return;
-    const timingLabel = this.comment?.id
-      ? REPORT_UPDATE_DRAFT
-      : REPORT_CREATE_DRAFT;
-    const timer = this.reporting.getTimer(timingLabel);
-    this.set('comment.__editing', false);
-    return this.save().then(() => {
-      timer.end({id: this.comment?.id});
-    });
-  }
-
-  _handleCancel(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    if (!this.comment.id) {
-      // Ensures we update the discarded draft message before deleting the draft
-      this.set('comment.message', this._messageText);
-      this._fireDiscard();
-    } else {
-      this.set('comment.__editing', false);
-      this.commentsService.cancelDraft(this.comment);
       this.editing = false;
+    } catch (e) {
+      this.unableToSave = true;
+      throw e;
+    } finally {
+      this.saving = false;
     }
   }
 
-  _fireDiscard() {
-    if (this.comment) this.commentsService.deleteDraft(this.comment);
-    this.fireUpdateTask?.cancel();
-    this.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _handleFix() {
-    this.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
-  }
-
-  _handleShowFix() {
-    this.dispatchEvent(
-      new CustomEvent('open-fix-preview', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
-  }
-
-  _hasNoFix(comment?: UIComment) {
-    return !comment || !(comment as UIRobot).fix_suggestions;
-  }
-
-  _handleDiscard(e: Event) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    this._discardDraft();
-  }
-
-  _discardDraft() {
-    if (!this.comment) return Promise.reject(new Error('undefined comment'));
-    if (!isDraft(this.comment)) {
-      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
-    }
-    this.discarding = true;
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftCommentFromStorage();
-
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return Promise.resolve();
-    }
-
-    this._xhrPromise = this._deleteDraft(this.comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
-        }
-        timer.end({id: this.comment?.id});
-        this._fireDiscard();
-        return response;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  _getSavingMessage(numPending: number, requestFailed?: boolean) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
-    }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return `Saving ${pluralize(numPending, 'draft')}...`;
-  }
-
-  _showStartRequest() {
-    const numPending = ++this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _showEndRequest() {
-    const numPending = --this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _handleFailedDraftRequest() {
-    this._numPendingDraftRequests.number--;
-
-    // Cancel the debouncer so that error toasts from the error-manager will
-    // not be overridden.
-    this.draftToastTask?.cancel();
-    this._updateRequestToast(
-      this._numPendingDraftRequests.number,
-      /* requestFailed=*/ true
-    );
-  }
-
-  _updateRequestToast(numPending: number, requestFailed?: boolean) {
-    const message = this._getSavingMessage(numPending, requestFailed);
-    this.draftToastTask = debounce(
-      this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
+  /** For sharing between save() and autoSave(). */
+  private rawSave(message: string, options: {showToast: boolean}) {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    return this.commentsModel.saveDraft(
+      {
+        ...this.comment,
+        message,
+        unresolved: this.unresolved,
       },
-      TOAST_DEBOUNCE_INTERVAL
+      options.showToast
     );
   }
 
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
+  private handleToggleResolved() {
+    this.unresolved = !this.unresolved;
+    if (!this.editing) this.save();
   }
 
-  _saveDraft(draft?: UIComment) {
-    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
-      throw new Error('undefined draft or changeNum or patchNum');
-    }
-    this._showStartRequest();
-    return this.restApiService
-      .saveDiffDraft(this.changeNum, this.patchNum, draft)
-      .then(result => {
-        if (result.ok) {
-          // remove
-          this._unableToSave = false;
-          this.$.container.classList.remove('unableToSave');
-          this._showEndRequest();
-        } else {
-          this._handleDraftFailure();
-        }
-        return result;
-      })
-      .catch(err => {
-        this._handleDraftFailure();
-        throw err;
-      });
+  private async openDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = true;
+    await this.updateComplete;
+    await this.confirmDeleteOverlay?.open();
   }
 
-  _deleteDraft(draft: UIComment) {
-    const changeNum = this.changeNum;
-    const patchNum = this.patchNum;
-    if (changeNum === undefined || patchNum === undefined) {
-      throw new Error('undefined changeNum or patchNum');
-    }
-    fireAlert(this, 'Discarding draft...');
-    const draftID = draft.id;
-    if (!draftID) throw new Error('Missing id in comment draft.');
-    return this.restApiService
-      .deleteDiffDraft(changeNum, patchNum, {id: draftID})
-      .then(result => {
-        if (result.ok) {
-          fire(this, 'show-alert', {
-            message: 'Draft Discarded',
-            action: 'Undo',
-            callback: () =>
-              this.commentsService.restoreDraft(changeNum, patchNum, draftID),
-          });
-        }
-        return result;
-      });
+  private closeDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = false;
+    this.confirmDeleteOverlay?.remove();
+    this.confirmDeleteOverlay?.close();
   }
 
-  _getPatchNum(): PatchSetNum {
-    const patchNum = this.isOnParent()
-      ? ('PARENT' as BasePatchSetNum)
-      : this.patchNum;
-    if (patchNum === undefined) throw new Error('patchNum undefined');
-    return patchNum;
-  }
-
-  @observe('changeNum', 'patchNum', 'comment')
-  _loadLocalDraft(
-    changeNum: number,
-    patchNum?: PatchSetNum,
-    comment?: UIComment
-  ) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that are drafts and are currently
-    // being edited.
-    if (
-      !comment ||
-      !comment.path ||
-      comment.message ||
-      !isDraft(comment) ||
-      !comment.__editing
-    ) {
-      return;
-    }
-
-    const draft = this.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this._messageText = draft.message || '';
-    }
-  }
-
-  _handleToggleResolved() {
-    this.reporting.recordDraftInteraction();
-    this.resolved = !this.resolved;
-    // Modify payload instead of this.comment, as this.comment is passed from
-    // the parent by ref.
-    const payload = this._getEventPayload();
-    if (!payload.comment) {
-      throw new Error('comment not defined in payload');
-    }
-    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-    this.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: payload,
-        composed: true,
-        bubbles: true,
-      })
-    );
-    if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
-    }
-  }
-
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
-  }
-
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
-  }
-
-  _openOverlay(overlay?: GrOverlay | null) {
-    if (!overlay) {
-      return Promise.reject(new Error('undefined overlay'));
-    }
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
-    if (!comment) return true;
-    if (!isRobot(comment)) return true;
-    return !comment.url || collapsed;
-  }
-
-  _closeOverlay(overlay?: GrOverlay | null) {
-    if (overlay) {
-      getRootElement().removeChild(overlay);
-      overlay.close();
-    }
-  }
-
-  _handleConfirmDeleteComment() {
+  /**
+   * Deleting a *published* comment is an admin feature. It means more than just
+   * discarding a draft.
+   *
+   * TODO: Also move this into the comments-service.
+   * TODO: Figure out a good reloading strategy when deleting was successful.
+   *       `this.comment = newComment` does not seem sufficient.
+   */
+  // private, but visible for testing
+  handleConfirmDeleteComment() {
     const dialog = this.confirmDeleteOverlay?.querySelector(
       '#confirmDeleteComment'
     ) as GrConfirmDeleteCommentDialog | null;
     if (!dialog || !dialog.message) {
       throw new Error('missing confirm delete dialog');
     }
-    if (
-      !this.comment ||
-      !this.comment.id ||
-      this.changeNum === undefined ||
-      this.patchNum === undefined
-    ) {
-      throw new Error('undefined comment or id or changeNum or patchNum');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.comment, 'comment');
+    assertIsDefined(this.comment.patch_set, 'comment.patch_set');
+    if (isDraftOrUnsaved(this.comment)) {
+      throw new Error('Admin deletion is only for published comments.');
     }
     this.restApiService
       .deleteComment(
         this.changeNum,
-        this.patchNum,
+        this.comment.patch_set,
         this.comment.id,
         dialog.message
       )
       .then(newComment => {
-        this._handleCancelDeleteComment();
+        this.closeDeleteCommentOverlay();
         this.comment = newComment;
       });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      font-family: var(--font-family);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) {
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .actions,
-    :host([disabled]) .robotActions,
-    :host([disabled]) .date {
-      opacity: 0.5;
-    }
-    :host([discarding]) {
-      display: none;
-    }
-    .body {
-      padding-top: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      cursor: pointer;
-      display: flex;
-    }
-    .headerLeft > span {
-      font-weight: var(--font-weight-bold);
-    }
-    .headerMiddle {
-      color: var(--deemphasized-text-color);
-      flex: 1;
-      overflow: hidden;
-    }
-    .draftLabel,
-    .draftTooltip {
-      color: var(--deemphasized-text-color);
-      display: none;
-    }
-    .date {
-      justify-content: flex-end;
-      text-align: right;
-      white-space: nowrap;
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .actions,
-    .robotActions {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: 0;
-    }
-    .robotActions {
-      /* Better than the negative margin would be to remove the gr-button
-       * padding, but then we would also need to fix the buttons that are
-       * inserted by plugins. :-/ */
-      margin: 4px 0 -4px;
-    }
-    .action {
-      margin-left: var(--spacing-l);
-    }
-    .rightActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    .rightActions gr-button {
-      --gr-button-padding: 0 var(--spacing-s);
-    }
-    .editMessage {
-      display: none;
-      margin: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .container:not(.draft) .actions .hideOnPublished {
-      display: none;
-    }
-    .draft .reply,
-    .draft .quote,
-    .draft .ack,
-    .draft .done {
-      display: none;
-    }
-    .draft .draftLabel,
-    .draft .draftTooltip {
-      display: inline;
-    }
-    .draft:not(.editing):not(.unableToSave) .save,
-    .draft:not(.editing) .cancel {
-      display: none;
-    }
-    .editing .message,
-    .editing .reply,
-    .editing .quote,
-    .editing .ack,
-    .editing .done,
-    .editing .edit,
-    .editing .discard,
-    .editing .unresolved {
-      display: none;
-    }
-    .editing .editMessage {
-      display: block;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-    }
-    .robotId {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .robotRun {
-      margin-left: var(--spacing-m);
-    }
-    .robotRunLink {
-      margin-left: var(--spacing-m);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-    }
-    label.show-hide iron-icon {
-      vertical-align: top;
-    }
-    #container .collapsedContent {
-      display: none;
-    }
-    #container.collapsed .body {
-      padding-top: 0;
-    }
-    #container.collapsed .collapsedContent {
-      display: block;
-      overflow: hidden;
-      padding-left: var(--spacing-m);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    #container.collapsed #deleteBtn,
-    #container.collapsed .date,
-    #container.collapsed .actions,
-    #container.collapsed gr-formatted-text,
-    #container.collapsed gr-textarea,
-    #container.collapsed .respectfulReviewTip {
-      display: none;
-    }
-    .resolve,
-    .unresolved {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      margin: 0;
-    }
-    .resolve label {
-      color: var(--comment-text-color);
-    }
-    gr-dialog .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    #deleteBtn {
-      display: none;
-      --gr-button-text-color: var(--deemphasized-text-color);
-      --gr-button-padding: 0;
-    }
-    #deleteBtn.showDeleteButtons {
-      display: block;
-    }
-
-    /** Disable select for the caret and actions */
-    .actions,
-    .show-hide {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .respectfulReviewTip {
-      justify-content: space-between;
-      display: flex;
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-bottom: var(--spacing-m);
-    }
-    .respectfulReviewTip div {
-      display: flex;
-    }
-    .respectfulReviewTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .respectfulReviewTip a {
-      white-space: nowrap;
-      margin-right: var(--spacing-s);
-      padding-left: var(--spacing-m);
-      text-decoration: none;
-    }
-    .pointer {
-      cursor: pointer;
-    }
-    .patchset-text {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-s);
-    }
-    .headerLeft gr-account-label {
-      --account-max-length: 130px;
-      width: 150px;
-    }
-    .headerLeft gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    .draft gr-account-label {
-      width: unset;
-    }
-    .portedMessage {
-      margin: 0 var(--spacing-m);
-    }
-    .link-icon {
-      cursor: pointer;
-    }
-  </style>
-  <div id="container" class="container">
-    <div class="header" id="header" on-click="_handleToggleCollapsed">
-      <div class="headerLeft">
-        <template is="dom-if" if="[[comment.robot_id]]">
-          <span class="robotName"> [[comment.robot_id]] </span>
-        </template>
-        <template is="dom-if" if="[[!comment.robot_id]]">
-          <gr-account-label
-            account="[[_getAuthor(comment, _selfAccount)]]"
-            class$="[[_computeAccountLabelClass(draft)]]"
-            hideStatus
-          >
-          </gr-account-label>
-        </template>
-        <template is="dom-if" if="[[showPortedComment]]">
-          <a href="[[_getUrlForComment(comment)]]"
-            ><span class="portedMessage" on-click="_handlePortedMessageClick"
-              >From patchset [[comment.patch_set]]</span
-            ></a
-          >
-        </template>
-        <gr-tooltip-content
-          class="draftTooltip"
-          has-tooltip
-          title="[[_computeDraftTooltip(_unableToSave)]]"
-          max-width="20em"
-          show-icon
-        >
-          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
-        </gr-tooltip-content>
-      </div>
-      <div class="headerMiddle">
-        <span class="collapsedContent">[[comment.message]]</span>
-      </div>
-      <div
-        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
-        class="runIdMessage message"
-      >
-        <div class="runIdInformation">
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun link">Run Details</span>
-          </a>
-        </div>
-      </div>
-      <gr-button
-        id="deleteBtn"
-        title="Delete Comment"
-        link=""
-        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-        hidden$="[[isRobotComment]]"
-        on-click="_handleCommentDelete"
-      >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
-      </gr-button>
-      <template is="dom-if" if="[[showPatchset]]">
-        <span class="patchset-text"> Patchset [[patchNum]]</span>
-      </template>
-      <span class="separator"></span>
-      <template is="dom-if" if="[[comment.updated]]">
-        <span class="date" tabindex="0" on-click="_handleAnchorClick">
-          <gr-date-formatter
-            withTooltip
-            date-str="[[comment.updated]]"
-          ></gr-date-formatter>
-        </span>
-      </template>
-      <div class="show-hide" tabindex="0">
-        <label
-          class="show-hide"
-          aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
-        >
-          <input
-            type="checkbox"
-            class="show-hide"
-            checked$="[[collapsed]]"
-            on-change="_handleToggleCollapsed"
-          />
-          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
-          </iron-icon>
-        </label>
-      </div>
-    </div>
-    <div class="body">
-      <template is="dom-if" if="[[isRobotComment]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.author.name]]
-        </div>
-      </template>
-      <template is="dom-if" if="[[editing]]">
-        <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          code=""
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"
-        ></gr-textarea>
-        <template
-          is="dom-if"
-          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
-        >
-          <div class="respectfulReviewTip">
-            <div>
-              <gr-tooltip-content
-                has-tooltip
-                title="Tips for respectful code reviews."
-              >
-                <iron-icon
-                  class="pointer"
-                  icon="gr-icons:lightbulb-outline"
-                ></iron-icon>
-              </gr-tooltip-content>
-              [[_respectfulReviewTip]]
-            </div>
-            <div>
-              <a
-                tabindex="-1"
-                on-click="_onRespectfulReadMoreClick"
-                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                target="_blank"
-              >
-                Read more
-              </a>
-              <a
-                tabindex="-1"
-                class="close pointer"
-                on-click="_dismissRespectfulTip"
-                >Not helpful</a
-              >
-            </div>
-          </div>
-        </template>
-      </template>
-      <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-      <gr-formatted-text
-        class="message"
-        content="[[comment.message]]"
-        no-trailing-margin="[[!comment.__draft]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input
-              type="checkbox"
-              id="resolvedCheckbox"
-              checked="[[resolved]]"
-              on-change="_handleToggleResolved"
-            />
-            Resolved
-          </label>
-        </div>
-        <template is="dom-if" if="[[draft]]">
-          <div class="rightActions">
-            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-              <iron-icon
-                class="link-icon"
-                on-click="handleCopyLink"
-                class="copy"
-                title="Copy link to this comment"
-                icon="gr-icons:link"
-                role="button"
-                tabindex="0"
-              >
-              </iron-icon>
-            </template>
-            <gr-button
-              link=""
-              class="action cancel hideOnPublished"
-              on-click="_handleCancel"
-              >Cancel</gr-button
-            >
-            <gr-button
-              link=""
-              class="action discard hideOnPublished"
-              on-click="_handleDiscard"
-              >Discard</gr-button
-            >
-            <gr-button
-              link=""
-              class="action edit hideOnPublished"
-              on-click="_handleEdit"
-              >Edit</gr-button
-            >
-            <gr-button
-              link=""
-              disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-              class="action save hideOnPublished"
-              on-click="_handleSave"
-              >Save</gr-button
-            >
-          </div>
-        </template>
-      </div>
-      <div class="robotActions" hidden$="[[!_showRobotActions]]">
-        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-        </template>
-        <template is="dom-if" if="[[isRobotComment]]">
-          <gr-endpoint-decorator name="robot-comment-controls">
-            <gr-endpoint-param name="comment" value="[[comment]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <gr-button
-            link=""
-            secondary=""
-            class="action show-fix"
-            hidden$="[[_hasNoFix(comment)]]"
-            on-click="_handleShowFix"
-          >
-            Show Fix
-          </gr-button>
-          <template is="dom-if" if="[[!_hasHumanReply]]">
-            <gr-button
-              link=""
-              class="action fix"
-              on-click="_handleFix"
-              disabled="[[robotButtonDisabled]]"
-            >
-              Please Fix
-            </gr-button>
-          </template>
-        </template>
-      </div>
-    </div>
-  </div>
-  <template is="dom-if" if="[[_enableOverlay]]">
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-      <gr-confirm-delete-comment-dialog
-        id="confirmDeleteComment"
-        on-confirm="_handleConfirmDeleteComment"
-        on-cancel="_handleCancelDeleteComment"
-      >
-      </gr-confirm-delete-comment-dialog>
-    </gr-overlay>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 8e87676..28a52dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -14,1631 +14,663 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
 import {
   queryAndAssert,
   stubRestApi,
   stubStorage,
-  spyStorage,
   query,
-  isVisible,
-  stubReporting,
+  pressKey,
+  listenOnce,
+  stubComments,
   mockPromise,
+  waitUntilCalled,
+  dispatch,
+  MockPromise,
 } from '../../../test/test-utils';
 import {
   AccountId,
   EmailAddress,
-  FixId,
   NumericChangeId,
-  ParsedJSON,
   PatchSetNum,
-  RobotId,
-  RobotRunId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createComment,
   createDraft,
   createFixSuggestionInfo,
+  createRobotComment,
 } from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+  CreateFixCommentEvent,
+  OpenFixPreviewEventDetail,
+} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-  <gr-comment draft="true"></gr-comment>
-`);
+import {DraftInfo} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
+import {SinonStub} from 'sinon';
 
 suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element: GrComment;
+  let element: GrComment;
 
-    let openOverlaySpy: sinon.SinonSpy;
+  setup(() => {
+    element = fixtureFromElement('gr-comment').instantiate();
+    element.account = {
+      email: 'dhruvsri@google.com' as EmailAddress,
+      name: 'Dhruv Srivastava',
+      _account_id: 1083225 as AccountId,
+      avatars: [{url: 'abc', height: 32, width: 32}],
+      registered_on: '123' as Timestamp,
+    };
+    element.showPatchset = true;
+    element.getRandomInt = () => 1;
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      id: 'baf0414d_60047215' as UrlEncodedCommentId,
+      line: 5,
+      message: 'This is the test comment message.',
+      updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+    };
+  });
 
-    setup(() => {
-      stubRestApi('getAccount').returns(
-        Promise.resolve({
-          email: 'dhruvsri@google.com' as EmailAddress,
-          name: 'Dhruv Srivastava',
-          _account_id: 1083225 as AccountId,
-          avatars: [{url: 'abc', height: 32, width: 32}],
-          registered_on: '123' as Timestamp,
-        })
-      );
-      element = basicFixture.instantiate();
-      element.getRandomNum = () => 1;
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
-    });
-
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
-    });
-
-    test('renders', async () => {
-      await flush();
+  suite('DOM rendering', () => {
+    test('renders collapsed', async () => {
+      element.initiallyCollapsed = true;
+      await element.updateComplete;
       expect(element).shadowDom.to.equal(`
-        <div class="collapsed container" id="container">
+        <div class="container" id="container">
           <div class="header" id="header">
             <div class="headerLeft">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
               <gr-account-label deselected="" hidestatus=""></gr-account-label>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <gr-tooltip-content
-                class="draftTooltip"
-                has-tooltip=""
-                max-width="20em"
-                show-icon=""
-                title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
-              >
-                <span class="draftLabel">DRAFT</span>
-              </gr-tooltip-content>
             </div>
             <div class="headerMiddle">
               <span class="collapsedContent">
-                is this a crossover episode!?
+                This is the test comment message.
               </span>
             </div>
-            <div class="message runIdMessage" hidden="true">
-              <div class="runIdInformation">
-                <a class="robotRunLink">
-                  <span class="link robotRun">
-                    Run Details
-                  </span>
-                </a>
-              </div>
+            <span class="patchset-text">Patchset 1</span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Expand" class="show-hide">
+                <input checked="" class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-more"></iron-icon>
+              </label>
             </div>
-            <gr-button
-              aria-disabled="false"
-              class="action delete"
-              id="deleteBtn"
-              link=""
-              role="button"
-              tabindex="0"
-              title="Delete Comment"
-            >
-              <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
-            </gr-button>
-            <span class="patchset-text">Patchset</span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+          </div>
+          <div class="body"></div>
+        </div>
+      `);
+    });
+
+    test('renders expanded', async () => {
+      element.initiallyCollapsed = false;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label deselected="" hidestatus=""></gr-account-label>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
             <span class="separator"></span>
             <span class="date" tabindex="0">
               <gr-date-formatter withtooltip=""></gr-date-formatter>
             </span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
             <div class="show-hide" tabindex="0">
-              <label aria-label="Expand" class="show-hide">
-                <input checked="true" class="show-hide" type="checkbox">
-                <iron-icon id="icon"></iron-icon>
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
               </label>
             </div>
           </div>
           <div class="body">
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-formatted-text class="message" notrailingmargin="">
-            </gr-formatted-text>
-            <div class="actions humanActions">
-              <div class="action hideOnPublished resolve">
-                <label>
-                  <input id="resolvedCheckbox" type="checkbox">
-                  Resolved
-                </label>
-              </div>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
-            <div class="robotActions">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
+            <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
           </div>
         </div>
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
       `);
     });
 
-    test('renders editing:true', async () => {
+    test('renders expanded robot', async () => {
+      element.initiallyCollapsed = false;
+      element.comment = createRobotComment();
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <span class="robotName">robot-id-123</span>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <div class="robotId"></div>
+            <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
+            <div class="robotActions">
+              <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0"
+                         title="Copy link to this comment">
+              </iron-icon>
+              <gr-endpoint-decorator name="robot-comment-controls">
+                <gr-endpoint-param name="comment"></gr-endpoint-param>
+              </gr-endpoint-decorator>
+              <gr-button aria-disabled="false" class="action show-fix" link="" role="button" secondary="" tabindex="0">
+                Show Fix
+              </gr-button>
+              <gr-button aria-disabled="false" class="action fix" link="" role="button" tabindex="0">
+                Please Fix
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `);
+    });
+
+    test('renders expanded admin', async () => {
+      element.initiallyCollapsed = false;
+      element.isAdmin = true;
+      await element.updateComplete;
+      expect(queryAndAssert(element, 'gr-button.delete')).dom.to.equal(`
+        <gr-button
+          aria-disabled="false"
+          class="action delete"
+          id="deleteBtn"
+          link=""
+          role="button"
+          tabindex="0"
+          title="Delete Comment"
+        >
+          <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
+        </gr-button>
+      `);
+    });
+
+    test('renders draft', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container draft" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
+              <gr-tooltip-content
+                class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+              >
+                <span class="draftLabel">DRAFT</span>
+              </gr-tooltip-content>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <gr-formatted-text class="message"></gr-formatted-text>
+            <div class="actions">
+              <div class="action resolve">
+                <label>
+                  <input checked="" id="resolvedCheckbox" type="checkbox">
+                  Resolved
+                </label>
+              </div>
+              <div class="rightActions">
+                <gr-button aria-disabled="false" class="action discard" link="" role="button" tabindex="0">
+                  Discard
+                </gr-button>
+                <gr-button aria-disabled="false" class="action edit" link="" role="button" tabindex="0">
+                  Edit
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
+    });
+
+    test('renders draft in editing mode', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
       element.editing = true;
-      await flush();
+      await element.updateComplete;
       expect(element).shadowDom.to.equal(`
-        <div class="collapsed container editing" id="container">
+        <div class="container draft" id="container">
           <div class="header" id="header">
             <div class="headerLeft">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <gr-account-label deselected="" hidestatus=""></gr-account-label>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+              <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
               <gr-tooltip-content
-                class="draftTooltip"
-                has-tooltip=""
-                max-width="20em"
-                show-icon=""
-                title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
+                class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
               >
                 <span class="draftLabel">DRAFT</span>
               </gr-tooltip-content>
             </div>
-            <div class="headerMiddle">
-              <span class="collapsedContent">
-                is this a crossover episode!?
-              </span>
-            </div>
-            <div class="message runIdMessage" hidden="true">
-              <div class="runIdInformation">
-                <a class="robotRunLink">
-                  <span class="link robotRun">
-                    Run Details
-                  </span>
-                </a>
-              </div>
-            </div>
-            <gr-button
-              aria-disabled="false"
-              class="action delete"
-              id="deleteBtn"
-              link=""
-              role="button"
-              tabindex="0"
-              title="Delete Comment"
-            >
-              <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
-            </gr-button>
-            <span class="patchset-text">Patchset</span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
             <span class="separator"></span>
             <span class="date" tabindex="0">
               <gr-date-formatter withtooltip=""></gr-date-formatter>
             </span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
             <div class="show-hide" tabindex="0">
-              <label aria-label="Expand" class="show-hide">
-                <input checked="true" class="show-hide" type="checkbox">
-                <iron-icon id="icon"></iron-icon>
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
               </label>
             </div>
           </div>
           <div class="body">
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-textarea autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4">
+            <gr-textarea
+              autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4"
+              text="This is the test comment message."
+            >
             </gr-textarea>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-formatted-text class="message" notrailingmargin="">
-            </gr-formatted-text>
-            <div class="actions humanActions">
-              <div class="action hideOnPublished resolve">
+            <div class="actions">
+              <div class="action resolve">
                 <label>
-                  <input id="resolvedCheckbox" type="checkbox">
+                  <input checked="" id="resolvedCheckbox" type="checkbox">
                   Resolved
                 </label>
               </div>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
-            <div class="robotActions">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+              <div class="rightActions">
+                <gr-button aria-disabled="false" class="action cancel" link="" role="button" tabindex="0">
+                  Cancel
+                </gr-button>
+                <gr-button aria-disabled="false" class="action save" link="" role="button" tabindex="0">
+                  Save
+                </gr-button>
+              </div>
             </div>
           </div>
         </div>
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
       `);
     });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = queryAndAssert(element, '.date');
-      assert.ok(dateEl);
-      tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {
-        side: element.side,
-        number: element.comment!.line,
-      });
-    });
-
-    test('message is not retrieved from storage when missing path', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is not retrieved from storage when message present', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        message: 'This is a message',
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is retrieved from storage for drafts in edit', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isTrue(storageStub.called);
-    });
-
-    test('comment message sets messageText only when empty', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = '';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('comment message sets messageText when not edited', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = 'Some text';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: false,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1 as PatchSetNum;
-      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-    });
-
-    suite('while editing', () => {
-      let handleCancelStub: sinon.SinonStub;
-      let handleSaveStub: sinon.SinonStub;
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        handleCancelStub = sinon.stub(element, '_handleCancel');
-        handleSaveStub = sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-          assert.isTrue(handleCancelStub.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('meta+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-          assert.isFalse(handleSaveStub.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-        assert.isFalse(handleCancelStub.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('meta+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('ctrl+s saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-        assert.isTrue(handleSaveStub.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment', async () => {
-      const stub = stubRestApi('deleteComment').returns(
-        Promise.resolve(createComment())
-      );
-      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
-      element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._isAdmin = true;
-      assert.isTrue(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-      tap(queryAndAssert(element, '.action.delete'));
-      await flush();
-      await openSpy.lastCall.returnValue;
-      const dialog = element.confirmDeleteOverlay?.querySelector(
-        '#confirmDeleteComment'
-      ) as GrConfirmDeleteCommentDialog;
-      dialog.message = 'removal reason';
-      element._handleConfirmDeleteComment();
-      assert.isTrue(
-        stub.calledWith(
-          42 as NumericChangeId,
-          1 as PatchSetNum,
-          'baf0414d_60047215' as UrlEncodedCommentId,
-          'removal reason'
-        )
-      );
-    });
-
-    suite('draft update reporting', () => {
-      let endStub: SinonStubbedMember<() => Timer>;
-      let getTimerStub: sinon.SinonStub;
-      const mockEvent = {...new Event('click'), preventDefault() {}};
-
-      setup(() => {
-        sinon.stub(element, 'save').returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        const mockTimer = new MockTimer();
-        mockTimer.end = endStub;
-        getTimerStub = stubReporting('getTimer').returns(mockTimer);
-      });
-
-      test('create', async () => {
-        element.patchNum = 1 as PatchSetNum;
-        element.comment = {};
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        await element._handleSave(mockEvent);
-        await flush();
-        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
-        const spanName = queryAndAssert<HTMLSpanElement>(
-          grAccountLabel,
-          'span.name'
-        );
-        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
-        assert.isTrue(endStub.calledOnce);
-        assert.isTrue(getTimerStub.calledOnce);
-        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-      });
-
-      test('update', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        element.comment = createDraft();
-        sinon.stub(element, '_fireDiscard');
-        sinon.stub(element, '_eraseDraftCommentFromStorage');
-        sinon
-          .stub(element, '_deleteDraft')
-          .returns(Promise.resolve(new Response()));
-        return element._discardDraft().then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_fireEdit');
-      element.draft = true;
-      flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_eraseDraftCommentFromStorage');
-      sinon.stub(element, '_fireDiscard');
-      sinon
-        .stub(element, '_deleteDraft')
-        .returns(Promise.resolve(new Response()));
-      element.draft = true;
-      element.comment = createDraft();
-      flush();
-      tap(queryAndAssert(element, '.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('failed save draft request', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({...new Response(), ok: false})
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
-
-    test('failed save draft request with promise failure', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.reject(new Error())
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
   });
 
-  suite('gr-comment draft tests', () => {
-    let element: GrComment;
+  test('clicking on date link fires event', async () => {
+    const stub = sinon.stub();
+    element.addEventListener('comment-anchor-tap', stub);
+    await element.updateComplete;
 
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
-      stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({
-          ...new Response(),
-          ok: true,
-          text() {
-            return Promise.resolve(
-              ")]}'\n{" +
-                '"id": "baf0414d_40572e03",' +
-                '"path": "/path/to/file",' +
-                '"line": 5,' +
-                '"updated": "2015-12-08 21:52:36.177000000",' +
-                '"message": "saved!",' +
-                '"side": "REVISION",' +
-                '"unresolved": false,' +
-                '"patch_set": 1' +
-                '}'
-            );
-          },
-        })
-      );
-      stubRestApi('removeChangeReviewer').returns(
-        Promise.resolve({...new Response(), ok: true})
-      );
-      element = draftFixture.instantiate() as GrComment;
-      stubStorage('getDraftComment').returns(null);
+    const dateEl = queryAndAssert(element, '.date');
+    tap(dateEl);
+
+    assert.isTrue(stub.called);
+    assert.deepEqual(stub.lastCall.args[0].detail, {
+      side: 'REVISION',
+      number: element.comment!.line,
+    });
+  });
+
+  test('comment message sets messageText only when empty', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = '';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment!.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('comment message sets messageText when not edited', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = 'Some text';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment!.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('delete comment', async () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.isAdmin = true;
+    await element.updateComplete;
+
+    const deleteButton = queryAndAssert(element, '.action.delete');
+    tap(deleteButton);
+    await element.updateComplete;
+
+    assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+      element.confirmDeleteOverlay,
+      '#confirmDeleteComment'
+    );
+    dialog.message = 'removal reason';
+    await element.updateComplete;
+
+    const stub = stubRestApi('deleteComment').returns(
+      Promise.resolve(createComment())
+    );
+    element.handleConfirmDeleteComment();
+    assert.isTrue(
+      stub.calledWith(
+        42 as NumericChangeId,
+        1 as PatchSetNum,
+        'baf0414d_60047215' as UrlEncodedCommentId,
+        'removal reason'
+      )
+    );
+  });
+
+  suite('gr-comment draft tests', () => {
+    setup(async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.editing = false;
       element.comment = {
         ...createComment(),
         __draft: true,
-        __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
-        id: undefined,
       };
     });
 
-    test('button visibility states', async () => {
-      element.showActions = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+    test('isSaveDisabled', async () => {
+      element.saving = false;
+      element.unresolved = true;
+      element.comment = {...createComment(), unresolved: true};
+      element.messageText = 'asdf';
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.showActions = true;
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.messageText = '';
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
 
-      element.draft = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.unresolved = false;
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.editing = true;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.draft = false;
-      element.editing = false;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      element.draft = true;
-      element.editing = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // Delete button is not hidden by default
-      assert.isFalse(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      await flush();
-      assert.isTrue(
-        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
-      );
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      await flush();
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
-        'none'
-      );
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-    });
-
-    test('collapsible drafts', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      await flush();
-      assert.isTrue(fireEditStub.called);
-      assert.isFalse(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-textarea')),
-        'textarea is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      tap(element.$.header);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-    });
-
-    test('robot comment layout', async () => {
-      const comment = {
-        robot_id: 'happy_robot_id' as RobotId,
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush;
-      let runIdMessage;
-      runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
-      assert.isFalse((runIdMessage as HTMLElement).hidden);
-
-      const runDetailsLink = queryAndAssert(
-        element,
-        '.robotRunLink'
-      ) as HTMLAnchorElement;
-      assert.isTrue(
-        runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
-      );
-
-      const robotServiceName = queryAndAssert(element, '.robotName');
-      assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
-      const authorName = queryAndAssert(element, '.robotId');
-      assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
-      element.collapsed = true;
-      await flush();
-      runIdMessage = queryAndAssert(element, '.runIdMessage');
-      assert.isTrue((runIdMessage as HTMLDivElement).hidden);
-    });
-
-    test('author name fallback to email', async () => {
-      const comment = {
-        url: '/robot/comment',
-        author: {
-          email: 'test@test.com' as EmailAddress,
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush();
-      const authorName = queryAndAssert(
-        queryAndAssert(element, 'gr-account-label'),
-        'span.name'
-      ) as HTMLSpanElement;
-      assert.equal(authorName.innerText.trim(), 'test@test.com');
-    });
-
-    test('patchset level comment', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const comment = {
-        ...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        line: undefined,
-        range: undefined,
-      };
-      element.comment = comment;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
-      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      await flush();
-      assert.isTrue(eraseMessageDraftSpy.called);
-    });
-
-    test('draft creation/cancellation', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isFalse(element.editing);
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element.comment!.message = '';
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      // Save should be disabled on an empty message.
-      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          promise.resolve();
-        }
-      });
-      tap(queryAndAssert(element, '.cancel'));
-      await flush();
-      element._messageText = '';
-      element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-      await promise;
-    });
-
-    test('draft discard removes message from storage', async () => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        promise.resolve();
-      });
-      element._handleDiscard({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await promise;
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
-      stubRestApi('getResponseObject').returns(
-        Promise.resolve({...(createDraft() as ParsedJSON)})
-      );
-      const saveDraftStub = sinon
-        .stub(element, '_saveDraft')
-        .returns(Promise.resolve({...new Response(), ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        saveDraftStub.restore();
-        sinon
-          .stub(element, '_saveDraft')
-          .returns(Promise.resolve({...new Response(), ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-        element._computeSaveDisabled('test', msgComment, false),
-        false
-      );
-      assert.equal(
-        element._computeSaveDisabled('test2', msgComment, false),
-        false
-      );
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      element.saving = true;
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
     });
 
     test('ctrl+s saves comment', async () => {
-      const promise = mockPromise();
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        promise.resolve();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
+      const spy = sinon.stub(element, 'save');
+      element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(
-        element.textarea!.$.textarea.textarea,
-        83,
-        'ctrl',
-        's'
-      );
-      await promise;
+      await element.updateComplete;
+      pressKey(element.textarea!.$.textarea.textarea, 's', Modifier.CTRL_KEY);
+      assert.isTrue(spy.called);
     });
 
-    test('draft saving/editing', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
+    test('save', async () => {
+      const savePromise = mockPromise<void>();
+      const stub = stubComments('saveDraft').returns(savePromise);
 
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      tickAndFlush(1);
-      element._messageText = 'good news, everyone!';
-      tickAndFlush(1);
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      await flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      tap(queryAndAssert(element, '.save'));
-
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when creating draft.'
-      );
-
-      let draft = await element._xhrPromise!;
-      const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
-        comment: DraftInfo;
-      }>;
-      assert.equal(evt.type, 'comment-save');
-
-      const expectedDetail = {
-        comment: {
-          ...createComment(),
-          __draft: true,
-          __draftID: 'temp_draft_id',
-          id: 'baf0414d_40572e03' as UrlEncodedCommentId,
-          line: 5,
-          message: 'saved!',
-          path: '/path/to/file',
-          updated: '2015-12-08 21:52:36.177000000' as Timestamp,
-        },
-        patchNum: 1 as PatchSetNum,
-      };
-
-      assert.deepEqual(evt.detail, expectedDetail);
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done creating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.calledTwice);
-      element._messageText =
-        'You’ll be delivering a package to Chapek 9, ' +
-        'a world where humans are killed on sight.';
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when updating draft.'
-      );
-      draft = await element._xhrPromise!;
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done updating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      dispatchEventStub.restore();
-    });
-
-    test('draft prevent save when disabled', async () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      sinon.stub(element, '_fireEdit');
-      element.showActions = true;
-      element.draft = true;
-      await flush();
-      tap(element.$.header);
-      tap(queryAndAssert(element, '.edit'));
-      element._messageText = 'good news, everyone!';
-      await flush();
-
-      element.disabled = true;
-      tap(queryAndAssert(element, '.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', async () => {
-      const save = sinon.stub(element, 'save');
-      const promise = mockPromise();
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        promise.resolve();
-      });
-      tap(queryAndAssert(element, '.resolve input'));
-      await promise;
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isFalse(save.called);
-      tap(element.$.resolvedCheckbox);
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', async () => {
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      tickAndFlush(1);
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await flush();
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({...new Event('click'), preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {
-        ...createComment(),
-        id: 'foo' as UrlEncodedCommentId,
-        message: 'test',
-      };
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      const textToSave = 'something, not important';
+      element.messageText = textToSave;
+      element.unresolved = true;
+      await element.updateComplete;
 
       element.save();
-      assert.isTrue(discardStub.called);
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, textToSave);
+      assert.equal(stub.lastCall.firstArg.unresolved, true);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('_handleFix fires create-fix event', async () => {
-      const promise = mockPromise();
-      element.addEventListener(
-        'create-fix-comment',
-        (e: CreateFixCommentEvent) => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          promise.resolve();
-        }
+    test('save failed', async () => {
+      stubComments('saveDraft').returns(
+        Promise.reject(new Error('saving failed'))
       );
-      element.isRobotComment = true;
-      element.comments = [element.comment!];
-      await flush();
 
-      tap(queryAndAssert(element, '.fix'));
-      await promise;
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      element.save();
+      await element.updateComplete;
+
+      assert.isTrue(element.unableToSave);
+      assert.isTrue(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: new Date(),
-          path: 'Documentation/config-gerrit.txt',
-          side: CommentSide.REVISION,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(
-        element.shadowRoot?.querySelector('robotActions gr-button')
-      );
+    test('discard', async () => {
+      const discardPromise = mockPromise<void>();
+      const stub = stubComments('discardDraft').returns(discardPromise);
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.discard();
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'discardDraft()');
+      assert.equal(stub.lastCall.firstArg, element.comment.id);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      discardPromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      queryAndAssert(element, '.robotActions gr-button');
-    });
-
-    test('_handleShowFix fires open-fix-preview event', async () => {
-      const promise = mockPromise();
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        promise.resolve();
-      });
+    test('resolved comment state indicated by checkbox', async () => {
+      const saveStub = sinon.stub(element, 'save');
       element.comment = {
         ...createComment(),
+        __draft: true,
+        unresolved: false,
+      };
+      await element.updateComplete;
+
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '#resolvedCheckbox'
+      );
+      assert.isTrue(checkbox.checked);
+
+      tap(checkbox);
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+      assert.isFalse(checkbox.checked);
+
+      assert.isTrue(saveStub.called);
+    });
+
+    test('saving empty text calls discard()', async () => {
+      const saveStub = stubComments('saveDraft');
+      const discardStub = stubComments('discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.messageText = '';
+      await element.updateComplete;
+
+      await element.save();
+      assert.isTrue(discardStub.called);
+      assert.isFalse(saveStub.called);
+    });
+
+    test('handleFix fires create-fix event', async () => {
+      const listener = listenOnce<CreateFixCommentEvent>(
+        element,
+        'create-fix-comment'
+      );
+      element.comment = createRobotComment();
+      element.comments = [element.comment!];
+      await element.updateComplete;
+
+      tap(queryAndAssert(element, '.fix'));
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
+    });
+
+    test('do not show Please Fix button if human reply exists', async () => {
+      element.initiallyCollapsed = false;
+      const robotComment = createRobotComment();
+      element.comment = robotComment;
+      await element.updateComplete;
+
+      let actions = query(element, '.robotActions gr-button.fix');
+      assert.isOk(actions);
+
+      element.comments = [
+        robotComment,
+        {...createComment(), in_reply_to: robotComment.id},
+      ];
+      await element.updateComplete;
+      actions = query(element, '.robotActions gr-button.fix');
+      assert.isNotOk(actions);
+    });
+
+    test('handleShowFix fires open-fix-preview event', async () => {
+      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+        element,
+        'open-fix-preview'
+      );
+      element.comment = {
+        ...createRobotComment(),
         fix_suggestions: [{...createFixSuggestionInfo()}],
       };
-      element.isRobotComment = true;
-      await flush();
+      await element.updateComplete;
 
       tap(queryAndAssert(element, '.show-fix'));
-      await promise;
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
+    });
+  });
+
+  suite('auto saving', () => {
+    let clock: sinon.SinonFakeTimers;
+    let savePromise: MockPromise<void>;
+    let saveStub: SinonStub;
+
+    setup(async () => {
+      clock = sinon.useFakeTimers();
+      savePromise = mockPromise<void>();
+      saveStub = stubComments('saveDraft').returns(savePromise);
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('basic auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'some new text  '});
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS / 2);
+      assert.isFalse(saveStub.called);
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(
+        saveStub.firstCall.firstArg.message,
+        'some new text  '.trimEnd()
+      );
+    });
+
+    test('saving while auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'auto save text'});
+
+      clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
+      saveStub.reset();
+
+      element.messageText = 'actual save text';
+      element.save();
+      await element.updateComplete;
+      // First wait for the auto saving to finish.
+      assert.isFalse(saveStub.called);
+
+      savePromise.resolve();
+      await element.updateComplete;
+      // Only then save.
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
     });
   });
 
   suite('respectful tips', () => {
-    let element: GrComment;
-
     let clock: sinon.SinonFakeTimers;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    setup(async () => {
       clock = sinon.useFakeTimers();
     });
 
@@ -1648,81 +680,81 @@
     });
 
     test('show tip when no cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+      queryAndAssert(element, '.respectfulReviewTip');
     });
 
     test('add 14-day delays once dismissed', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
       assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+      const closeLink = queryAndAssert(element, '.respectfulReviewTip a.close');
+      tap(closeLink);
+      await element.updateComplete;
 
-      tap(queryAndAssert(element, '.respectfulReviewTip .close'));
-      flush();
       assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
     });
 
     test('do not show tip when fall out of probability', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 2;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
     });
 
     test('show tip when editing changed to true', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      await flush();
+      element.editing = false;
+      element.getRandomInt = () => 0;
+      element.comment = createComment();
+      await element.updateComplete;
+
       assert.isFalse(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
 
       element.editing = true;
-      await flush();
+      await element.updateComplete;
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
       assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
     });
 
     test('no tip when cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns({updated: 0});
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 4657020..23d9693 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -25,7 +25,6 @@
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 import {getAppContext} from '../../../services/app-context';
-import {diffPreferences$} from '../../../services/user/user-model';
 
 export interface GrDiffPreferences {
   $: {
@@ -56,14 +55,14 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this.diffPrefs = diffPreferences;
       })
     );
@@ -142,7 +141,7 @@
 
   async save() {
     if (!this.diffPrefs) return;
-    await this.userService.updateDiffPreference(this.diffPrefs);
+    await this.userModel.updateDiffPreference(this.diffPrefs);
     this.hasUnsavedChanges = false;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 41ac3e3..8abd679 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -55,6 +55,109 @@
   });
 
   test('renders', () => {
+    expect(element).shadowDom.to.equal(`<div
+      class="gr-form-styles"
+      id="diffPreferences"
+    >
+    <section>
+      <label class="title" for="contextLineSelect">Context</label>
+      <span class="value">
+        <gr-select id="contextSelect">
+          <select id="contextLineSelect">
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </gr-select>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="lineWrappingInput">Fit to screen</label>
+      <span class="value">
+        <input id="lineWrappingInput" type="checkbox">
+      </span>
+    </section>
+    <section>
+      <label class="title" for="columnsInput">Diff width</label>
+      <span class="value">
+        <iron-input allowed-pattern="[0-9]">
+          <input id="columnsInput" type="number">
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="tabSizeInput">Tab width</label>
+      <span class="value">
+        <iron-input allowed-pattern="[0-9]">
+          <input id="tabSizeInput" type="number">
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="fontSizeInput">Font size</label>
+      <span class="value">
+        <iron-input allowed-pattern="[0-9]">
+          <input id="fontSizeInput" type="number">
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <label class="title" for="showTabsInput">Show tabs</label>
+      <span class="value">
+        <input id="showTabsInput" type="checkbox">
+      </span>
+    </section>
+    <section>
+      <label class="title" for="showTrailingWhitespaceInput">
+        Show trailing whitespace
+      </label>
+      <span class="value">
+        <input id="showTrailingWhitespaceInput" type="checkbox">
+      </span>
+    </section>
+    <section>
+      <label class="title" for="syntaxHighlightInput">
+        Syntax highlighting
+      </label>
+      <span class="value">
+        <input id="syntaxHighlightInput" type="checkbox">
+      </span>
+    </section>
+    <section>
+      <label class="title" for="automaticReviewInput">
+        Automatically mark viewed files reviewed
+      </label>
+      <span class="value">
+        <input id="automaticReviewInput" type="checkbox">
+      </span>
+    </section>
+    <section>
+      <div class="pref">
+        <label class="title" for="ignoreWhiteSpace">
+          Ignore Whitespace
+        </label>
+        <span class="value">
+          <gr-select>
+            <select id="ignoreWhiteSpace">
+              <option value="IGNORE_NONE">None</option>
+              <option value="IGNORE_TRAILING">Trailing</option>
+              <option value="IGNORE_LEADING_AND_TRAILING">
+                Leading & trailing
+              </option>
+              <option value="IGNORE_ALL">All</option>
+            </select>
+          </gr-select>
+        </span>
+      </div>
+    </section>
+  </div>`);
+  });
+
+  test('renders preferences', () => {
     // Rendered with the expected preferences selected.
     const contextInput = valueOf('Context', 'diffPreferences')
       .firstElementChild as IronInputElement;
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 68d8d7d..6eb19da 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
@@ -27,7 +27,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
-import {preferences$} from '../../../services/user/user-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -73,7 +72,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -83,7 +83,7 @@
       this._loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      preferences$.subscribe(prefs => {
+      this.userModel.preferences$.subscribe(prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -113,7 +113,7 @@
     if (scheme && scheme !== this.selectedScheme) {
       this.set('selectedScheme', scheme);
       if (this._loggedIn) {
-        this.userService.updatePreferences({
+        this.userModel.updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 6cbef79..bd0ca70 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -19,7 +19,6 @@
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
 import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {updatePreferences} from '../../../services/user/user-model';
 import {createPreferences} from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
@@ -116,7 +115,7 @@
     test('loads scheme from preferences', async () => {
       const element = basicFixture.instantiate();
       await flush();
-      updatePreferences({
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
@@ -126,7 +125,7 @@
     test('normalize scheme from preferences', async () => {
       const element = basicFixture.instantiate();
       await flush();
-      updatePreferences({
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
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 6180f35..a39d033 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
@@ -78,7 +78,7 @@
   @property({type: Number})
   initialCount = 75;
 
-  @property({type: Object})
+  @property({type: Array})
   items?: DropdownItem[];
 
   @property({type: String})
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 2b56de6..4045b6d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -133,19 +133,19 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+      addShortcut(this, {key: Key.UP}, () => this._handleUp())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+      addShortcut(this, {key: Key.TAB}, () => this._handleTab())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.SPACE}, () => this._handleEnter())
     );
   }
 
@@ -159,10 +159,8 @@
   /**
    * Handle the up key.
    */
-  _handleUp(e: Event) {
+  _handleUp() {
     if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
       this.cursor.previous();
     } else {
       this._open();
@@ -172,10 +170,8 @@
   /**
    * Handle the down key.
    */
-  _handleDown(e: Event) {
+  _handleDown() {
     if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
       this.cursor.next();
     } else {
       this._open();
@@ -185,20 +181,14 @@
   /**
    * Handle the tab key.
    */
-  _handleTab(e: Event) {
-    if (this.$.dropdown.opened) {
-      // Tab in a native select is a no-op. Emulate this.
-      e.preventDefault();
-      e.stopPropagation();
-    }
+  _handleTab() {
+    // Tab in a native select is a no-op. Emulate this.
   }
 
   /**
    * Handle the enter key.
    */
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleEnter() {
     if (this.$.dropdown.opened) {
       // TODO(milutin): This solution is not particularly robust in general.
       // Since gr-tooltip-content click on shadow dom is not propagated down,
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index e0d1d15..7e6c17c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -218,7 +218,6 @@
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      event.preventDefault();
       this._save();
     }
   }
@@ -229,7 +228,6 @@
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      event.preventDefault();
       this._cancel();
     }
   }
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 996edf3..1088b27 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
@@ -17,9 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-annotation-actions-js-api tests', () => {
   let annotationActions;
@@ -27,7 +24,7 @@
   let plugin;
 
   setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     annotationActions = plugin.annotationApi();
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 87f6052..b70c8ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -19,12 +19,8 @@
 import '../../change/gr-change-actions/gr-change-actions.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
 const basicFixture = fixtureFromElement('gr-change-actions');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-change-actions-js-api-interface tests', () => {
   let element;
   let changeActions;
@@ -41,7 +37,7 @@
   suite('early init', () => {
     setup(() => {
       resetPlugins();
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       // Mimic all plugins loaded.
       getPluginLoader().loadPlugins([]);
@@ -68,7 +64,7 @@
       sinon.stub(element, '_editStatusChanged');
       element.change = {};
       element._hasKnownChainState = false;
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeActions = plugin.changeActions();
       // Mimic all plugins loaded.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 2324588..52d6ab3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -17,16 +17,12 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-change-reply-js-api tests', () => {
   let element;
-
   let changeReply;
   let plugin;
 
@@ -36,7 +32,7 @@
 
   suite('early init', () => {
     setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
       element = basicFixture.instantiate();
@@ -64,7 +60,7 @@
   suite('normal init', () => {
     setup(() => {
       element = basicFixture.instantiate();
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
     });
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 d76b2b7..07fad80 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
@@ -81,12 +81,12 @@
   Auth: AuthService;
 }
 
-export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit ?? new GerritImpl(getAppContext());
+export function initGerritPluginApi(appContext: AppContext) {
+  window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
 }
 
-export function _testOnly_initGerritPluginApi(): GerritInternal {
-  initGerritPluginApi();
+export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
+  if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
   return window.Gerrit as GerritInternal;
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index ae0c370..d53c266 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {_testOnly_getGerritInternalPluginApi} from './gr-gerrit.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {getAppContext} from '../../../services/app-context.js';
 
@@ -33,7 +33,7 @@
     stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
     stubRestApi('send').returns(Promise.resolve({status: 200}));
     element = getAppContext().jsApiService;
-    pluginApi = _testOnly_initGerritPluginApi();
+    pluginApi = _testOnly_getGerritInternalPluginApi();
   });
 
   teardown(() => {
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 a48f91c..c45bbf5 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
@@ -21,13 +21,10 @@
 import {EventType} from '../../../api/plugin.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {getAppContext} from '../../../services/app-context.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('GrJsApiInterface tests', () => {
   let element;
   let plugin;
@@ -47,7 +44,7 @@
     sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
     element = getAppContext().jsApiService;
     errorStub = sinon.stub(element.reporting, 'error');
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     getPluginLoader().loadPlugins([]);
   });
@@ -300,7 +297,7 @@
     setup(() => {
       stubBaseUrl('/r');
 
-      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+      window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
           'http://test.com/r/plugins/baseurlplugin/static/test.js');
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index 34c976a..d4b93a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -18,18 +18,15 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {addListenerForTest} from '../../../test/test-utils.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-plugin-action-context tests', () => {
   let instance;
 
   let plugin;
 
   setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginActionContext(plugin);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index c7bdfb4..16846f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -18,12 +18,9 @@
 import {resetPlugins} from '../../../test/test-utils';
 import './gr-js-api-interface';
 import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 export class MockHook<T extends PluginElement> implements HookApi<T> {
   handleInstanceDetached(_: T) {}
 
@@ -59,7 +56,7 @@
   setup(() => {
     domHook = new MockHook<PluginElement>();
     instance = new GrPluginEndpoints();
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => (decoratePlugin = plugin),
       '0.1',
       'http://test.com/plugins/testplugin/static/decorate.js'
@@ -70,7 +67,7 @@
       moduleName: 'decorate-module',
       domHook,
     });
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => (stylePlugin = plugin),
       '0.1',
       'http://test.com/plugins/testplugin/static/style.js'
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 16656d2..ba6de67 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
@@ -22,6 +22,7 @@
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -209,9 +210,11 @@
       this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
     }
     this._checkIfCompleted();
-    return `Timeout when loading plugins: ${pending
+    const errorMessage = `Timeout when loading plugins: ${pending
       .map(p => p.name)
       .join(',')}`;
+    fireAlert(document, errorMessage);
+    return errorMessage;
   }
 
   _failToLoad(message: string, pluginUrl?: string) {
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 ab69267..e097858 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
@@ -19,11 +19,8 @@
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-plugin-loader tests', () => {
   let plugin;
 
@@ -47,18 +44,18 @@
   });
 
   test('reuse plugin for install calls', () => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
 
     let otherPlugin;
-    pluginApi.install(p => { otherPlugin = p; }, '0.1',
+    window.Gerrit.install(p => { otherPlugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     assert.strictEqual(plugin, otherPlugin);
   });
 
   test('versioning', () => {
     const callback = sinon.spy();
-    pluginApi.install(callback, '0.0pre-alpha');
+    window.Gerrit.install(callback, '0.0pre-alpha');
     assert(callback.notCalled);
   });
 
@@ -89,7 +86,7 @@
 
   test('plugins installed successfully', async () => {
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
+      window.Gerrit.install(() => void 0, undefined, url);
     });
     const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
         'pluginsLoaded');
@@ -107,7 +104,7 @@
 
   test('isPluginEnabled and isPluginLoaded', () => {
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
+      window.Gerrit.install(() => void 0, undefined, url);
     });
 
     const plugins = [
@@ -137,7 +134,7 @@
     addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
+      window.Gerrit.install(() => {
         if (url === plugins[0]) {
           throw new Error('failed');
         }
@@ -165,7 +162,7 @@
     addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
+      window.Gerrit.install(() => {
         if (url === plugins[0]) {
           throw new Error('failed');
         }
@@ -198,7 +195,7 @@
     addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
+      window.Gerrit.install(() => {
         throw new Error('failed');
       }, undefined, url);
     });
@@ -224,7 +221,7 @@
     addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
+      window.Gerrit.install(() => {
       }, url === plugins[0] ? '' : 'alpha', url);
     });
 
@@ -241,7 +238,7 @@
 
   test('multiple assets for same plugin installed successfully', async () => {
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
+      window.Gerrit.install(() => void 0, undefined, url);
     });
     const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
         'pluginsLoaded');
@@ -388,7 +385,7 @@
       }
     }
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => pluginCallback(url), undefined, url);
+      window.Gerrit.install(() => pluginCallback(url), undefined, url);
     });
 
     pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index d2b5658..730f163 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -18,11 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
 import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-plugin-rest-api tests', () => {
   let instance;
   let getResponseObjectStub;
@@ -33,7 +30,7 @@
     getResponseObjectStub = stubRestApi('getResponseObject').returns(
         Promise.resolve());
     sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
-    pluginApi.install(p => {}, '0.1',
+    window.Gerrit.install(p => {}, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginRestApi();
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
index a0f2e02..c96a075 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {GerritInternal, _testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {getAppContext} from '../../../services/app-context.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {PluginApi} from '../../../api/plugin.js';
@@ -27,10 +26,8 @@
 suite('gr-reporting-js-api tests', () => {
   let plugin: PluginApi;
   let reportingService: ReportingService;
-  let pluginApi: GerritInternal;
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     reportingService = getAppContext().reportingService;
   });
@@ -38,7 +35,7 @@
   suite('early init', () => {
     let reporting: ReportingPluginApi;
     setup(() => {
-      pluginApi.install(
+      window.Gerrit.install(
         p => {
           plugin = p;
         },
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 888f477..70de130 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -44,6 +44,7 @@
   getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
+  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -53,7 +54,6 @@
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {fireReload} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {sortReviewers} from '../../../utils/attention-set-util';
 
 declare global {
@@ -104,10 +104,6 @@
   @property({type: Boolean})
   showAllReviewers = true;
 
-  /** temporary until submit requirements are finished */
-  @property({type: Boolean})
-  showAlwaysOldUI = false;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -214,10 +210,7 @@
   }
 
   override render() {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
-      !this.showAlwaysOldUI
-    ) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       return this.renderNewSubmitRequirements();
     } else {
       return this.renderOldSubmitRequirements();
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-impl.ts
similarity index 97%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
index a3dc479..37b14b3 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-impl.ts
@@ -156,6 +156,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {ErrorCallback} from '../../../api/rest';
 import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
+import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -269,12 +270,12 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-rest-api-interface': GrRestApiInterface;
+    'gr-rest-api-service-impl': GrRestApiServiceImpl;
   }
 }
 
-@customElement('gr-rest-api-interface')
-export class GrRestApiInterface
+@customElement('gr-rest-api-service-impl')
+export class GrRestApiServiceImpl
   extends PolymerElement
   implements RestApiService, Finalizable
 {
@@ -1180,20 +1181,6 @@
     return listChangesOptionsToHex(...options);
   }
 
-  getDiffChangeDetail(changeNum: NumericChangeId) {
-    let optionsHex = '';
-    if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
-      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
-    } else {
-      optionsHex = listChangesOptionsToHex(
-        ListChangesOption.ALL_COMMITS,
-        ListChangesOption.ALL_REVISIONS,
-        ListChangesOption.SKIP_DIFFSTAT
-      );
-    }
-    return this._getChangeDetail(changeNum, optionsHex);
-  }
-
   /**
    * @param optionsHex list changes options in hex
    */
@@ -1703,11 +1690,12 @@
   }
 
   getChangesSubmittedTogether(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    options: string[] = ['NON_VISIBLE_CHANGES']
   ): Promise<SubmittedTogetherInfo | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
-      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+      endpoint: `/submitted_together?o=${options.join('&o=')}`,
       reportEndpointAsIs: true,
     }) as Promise<SubmittedTogetherInfo | undefined>;
   }
@@ -1758,22 +1746,27 @@
 
   getChangesWithSameTopic(
     topic: string,
-    changeNum: NumericChangeId
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
   ): Promise<ChangeInfo[] | undefined> {
-    const options = listChangesOptionsToHex(
+    const requestOptions = listChangesOptionsToHex(
       ListChangesOption.LABELS,
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
       ListChangesOption.DETAILED_LABELS
     );
-    const query = [
-      'status:open',
-      `-change:${changeNum}`,
-      `topic:"${topic}"`,
-    ].join(' ');
+    const queryTerms = [`topic:"${topic}"`];
+    if (options?.openChangesOnly) {
+      queryTerms.push('status:open');
+    }
+    if (options?.changeToExclude !== undefined) {
+      queryTerms.push(`-change:${options.changeToExclude}`);
+    }
     const params = {
-      O: options,
-      q: query,
+      O: requestOptions,
+      q: queryTerms.join(' '),
     };
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
@@ -2284,45 +2277,16 @@
    * is no logged in user, the request is not made and the promise yields an
    * empty object.
    */
-  getDiffDrafts(
+  async getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return {};
-      }
-      if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts', {
-          'enable-context': true,
-          'context-padding': 3,
-        });
-      }
-      return this._getDiffComments(
-        changeNum,
-        '/drafts',
-        {
-          'enable-context': true,
-          'context-padding': 3,
-        },
-        basePatchNum,
-        patchNum,
-        path
-      );
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = await this._getDiffComments(changeNum, '/drafts', {
+      'enable-context': true,
+      'context-padding': 3,
     });
+    return addDraftProp(comments);
   }
 
   _setRange(comments: CommentInfo[], comment: CommentInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
similarity index 99%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
index 0d3978a..b3f751a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
@@ -27,9 +27,9 @@
   readResponsePayload,
 } from './gr-rest-apis/gr-rest-api-helper.js';
 import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
-import {GrRestApiInterface} from './gr-rest-api-interface.js';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl.js';
 
-suite('gr-rest-api-interface tests', () => {
+suite('gr-rest-api-service-impl tests', () => {
   let element;
 
   let ctr = 0;
@@ -51,7 +51,10 @@
     // fake auth
     sinon.stub(getAppContext().authService, 'authCheck')
         .returns(Promise.resolve(true));
-    element = new GrRestApiInterface();
+    element = new GrRestApiServiceImpl(
+        getAppContext().authService,
+        getAppContext().flagsService
+    );
     element._projectLookup = {};
   });
 
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 8d833ca..b602a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -153,19 +153,29 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e), {
+        doNotPrevent: true,
+      })
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e), {
+        doNotPrevent: true,
+      })
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e), {
+        doNotPrevent: true,
+      })
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e), {
+        doNotPrevent: true,
+      })
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e), {
+        doNotPrevent: true,
+      })
     );
   }
 
@@ -414,6 +424,9 @@
   }
 
   _handleTextChanged(text: string) {
+    // This is a bit redundant, because the `text` property has `notify:true`,
+    // so whenever the `text` changes the component fires two identical events
+    // `text-changed` and `value-changed`.
     this.dispatchEvent(
       new CustomEvent('value-changed', {detail: {value: text}})
     );
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
similarity index 69%
rename from polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts
rename to polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
index c3fed91..234f058 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
@@ -17,28 +17,34 @@
 
 import './gr-topic-tree-row';
 import {customElement, property} from 'lit/decorators';
-import {LitElement, html} from 'lit-element/lit-element';
+import {LitElement, html, css} from 'lit-element/lit-element';
 import '../shared/gr-button/gr-button';
-import {ChangeInfo} from '../../api/rest-api';
+import {ChangeInfo, RepoName} from '../../api/rest-api';
 
 /**
- * A view of changes that all belong to the same project.
+ * A view of changes that all belong to the same repository.
  */
-@customElement('gr-topic-tree-project')
-export class GrTopicTreeProject extends LitElement {
+@customElement('gr-topic-tree-repo')
+export class GrTopicTreeRepo extends LitElement {
   @property({type: String})
-  projectName?: string;
+  repoName?: RepoName;
 
   @property({type: Array})
   changes?: ChangeInfo[];
 
+  static override styles = css`
+    :host {
+      display: contents;
+    }
+  `;
+
   override render() {
-    if (this.projectName === undefined || this.changes === undefined) {
+    if (this.repoName === undefined || this.changes === undefined) {
       return;
     }
-    // TODO: Groups of related changes should be separated within the project.
+    // TODO: Groups of related changes should be separated within the repository.
     return html`
-      <h2>Project ${this.projectName}</h2>
+      <h2>Repo ${this.repoName}</h2>
       ${this.changes.map(change => this.renderTreeRow(change))}
     `;
   }
@@ -50,6 +56,6 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-topic-tree-project': GrTopicTreeProject;
+    'gr-topic-tree-repo': GrTopicTreeRepo;
   }
 }
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
similarity index 69%
rename from polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts
rename to polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
index 39398bd..2e903b5 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
+import {RepoName} from '../../api/rest-api';
 import '../../test/common-test-setup-karma';
 import {createChange} from '../../test/test-data-generators';
 import {queryAndAssert} from '../../test/test-utils';
-import './gr-topic-tree-project';
-import {GrTopicTreeProject} from './gr-topic-tree-project';
+import './gr-topic-tree-repo';
+import {GrTopicTreeRepo} from './gr-topic-tree-repo';
 
-const basicFixture = fixtureFromElement('gr-topic-tree-project');
-const projectName = 'myProject';
+const basicFixture = fixtureFromElement('gr-topic-tree-repo');
+const repoName = 'myRepo' as RepoName;
 
-suite('gr-topic-tree-project tests', () => {
-  let element: GrTopicTreeProject;
+suite('gr-topic-tree-repo tests', () => {
+  let element: GrTopicTreeRepo;
 
   setup(async () => {
     element = basicFixture.instantiate();
-    element.projectName = projectName;
+    element.repoName = repoName;
     element.changes = [createChange()];
     await element.updateComplete;
   });
 
-  test('shows project name', () => {
+  test('shows repository name', () => {
     const heading = queryAndAssert<HTMLHeadingElement>(element, 'h2');
-    assert.equal(heading.textContent, `Project ${projectName}`);
+    assert.equal(heading.textContent, `Repo ${repoName}`);
   });
 });
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
index ac6031d..0355bee 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
@@ -16,7 +16,7 @@
  */
 
 import {customElement, property} from 'lit/decorators';
-import {LitElement, html} from 'lit-element/lit-element';
+import {LitElement, html, css} from 'lit-element/lit-element';
 import '../shared/gr-button/gr-button';
 import {ChangeInfo} from '../../api/rest-api';
 
@@ -36,17 +36,28 @@
   @property({type: Object})
   change?: ChangeInfo;
 
+  static override styles = css`
+    :host {
+      display: contents;
+    }
+  `;
+
   override render() {
     if (this.change === undefined) {
       return;
     }
+    const authorName =
+      this.change.revisions?.[this.change.current_revision!].commit?.author
+        .name;
     return html`
-      <span>${this.computeSize(this.change)}</span>
-      <span>${this.change.subject}</span>
-      <span>${this.change.topic}</span>
-      <span>${this.change.branch}</span>
-      <span>${this.change.owner.name}</span>
-      <span>${this.change.status}</span>
+      <tr>
+        <td>${this.computeSize(this.change)}</td>
+        <td>${this.change.subject}</td>
+        <td>${this.change.topic}</td>
+        <td>${this.change.branch}</td>
+        <td>${authorName}</td>
+        <td>${this.change.status}</td>
+      </tr>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
index a664845..e73cf13 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
@@ -18,8 +18,7 @@
 import {ChangeInfo, ChangeStatus, TopicName} from '../../api/rest-api';
 import '../../test/common-test-setup-karma';
 import {
-  createAccountWithIdNameAndEmail,
-  createChange,
+  createChangeViewChange,
   TEST_BRANCH_ID,
   TEST_SUBJECT,
 } from '../../test/test-data-generators';
@@ -31,12 +30,10 @@
 
 suite('gr-topic-tree-row tests', () => {
   let element: GrTopicTreeRow;
-  const owner = createAccountWithIdNameAndEmail();
   const change: ChangeInfo = {
-    ...createChange(),
+    ...createChangeViewChange(),
     insertions: 50,
     topic: 'myTopic' as TopicName,
-    owner,
   };
 
   setup(async () => {
@@ -46,12 +43,12 @@
   });
 
   test('shows columns of change information', () => {
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, 'M');
     assert.equal(columns[1].textContent, TEST_SUBJECT);
     assert.equal(columns[2].textContent, 'myTopic');
     assert.equal(columns[3].textContent, TEST_BRANCH_ID);
-    assert.equal(columns[4].textContent, owner.name);
+    assert.equal(columns[4].textContent, 'Test name');
     assert.equal(columns[5].textContent, ChangeStatus.NEW);
   });
 
@@ -59,7 +56,7 @@
     element.change = {...change, insertions: 0, deletions: 0};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, '');
   });
 
@@ -67,7 +64,7 @@
     element.change = {...change, insertions: 3, deletions: 6};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, 'XS');
   });
 
@@ -75,7 +72,7 @@
     element.change = {...change, insertions: 9, deletions: 40};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, 'S');
   });
 
@@ -83,7 +80,7 @@
     element.change = {...change, insertions: 249, deletions: 0};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLSpanElement>(element, 'td');
     assert.equal(columns[0].textContent, 'M');
   });
 
@@ -91,7 +88,7 @@
     element.change = {...change, insertions: 499, deletions: 500};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, 'L');
   });
 
@@ -99,7 +96,7 @@
     element.change = {...change, insertions: 1000, deletions: 1};
     await element.updateComplete;
 
-    const columns = queryAll<HTMLSpanElement>(element, 'span');
+    const columns = queryAll<HTMLTableCellElement>(element, 'td');
     assert.equal(columns[0].textContent, 'XL');
   });
 });
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
index da67e26..a993d90 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import './gr-topic-tree-project';
+import './gr-topic-tree-repo';
 import {customElement, property, state} from 'lit/decorators';
 import {LitElement, html, PropertyValues} from 'lit-element/lit-element';
 import {getAppContext} from '../../services/app-context';
@@ -24,7 +24,7 @@
 
 /**
  * A tree-like dashboard showing changes related to a topic, organized by
- * project.
+ * repository.
  */
 @customElement('gr-topic-tree')
 export class GrTopicTree extends LitElement {
@@ -32,7 +32,7 @@
   topicName?: string;
 
   @state()
-  private changesByProject = new Map<RepoName, ChangeInfo[]>();
+  private changesByRepo = new Map<RepoName, ChangeInfo[]>();
 
   private restApiService = getAppContext().restApiService;
 
@@ -44,39 +44,69 @@
   }
 
   override render() {
-    // TODO: organize into <table> for column alignment.
-    return Array.from(this.changesByProject).map(([projectName, changes]) =>
-      this.renderProjectSection(projectName, changes)
-    );
+    return html`
+      <table>
+        <thead>
+          <tr>
+            <td>Size</td>
+            <td>Subject</td>
+            <td>Topic</td>
+            <td>Branch</td>
+            <td>Owner</td>
+            <td>Status</td>
+          </tr>
+        </thead>
+        <tbody>
+          ${Array.from(this.changesByRepo).map(([repoName, changes]) =>
+            this.renderRepoSection(repoName, changes)
+          )}
+        </tbody>
+      </table>
+    `;
   }
 
-  private renderProjectSection(projectName: RepoName, changes: ChangeInfo[]) {
+  private renderRepoSection(repoName: RepoName, changes: ChangeInfo[]) {
     return html`
-      <gr-topic-tree-project
-        .projectName=${projectName}
+      <gr-topic-tree-repo
+        .repoName=${repoName}
         .changes=${changes}
-      ></gr-topic-tree-project>
+      ></gr-topic-tree-repo>
     `;
   }
 
   private async loadAndSortChangesFromTopic(): Promise<void> {
-    const changes = await this.restApiService.getChanges(
-      undefined /* changesPerPage */,
-      `topic:${this.topicName}`
+    const changesInTopic = this.topicName
+      ? await this.restApiService.getChangesWithSameTopic(this.topicName)
+      : [];
+    const changesSubmittedTogether = await this.loadChangesSubmittedTogether(
+      changesInTopic
     );
-    if (!changes) {
-      return;
-    }
-    this.changesByProject.clear();
-    for (const change of changes) {
-      if (this.changesByProject.has(change.project)) {
-        this.changesByProject.get(change.project)!.push(change);
+    this.changesByRepo.clear();
+    for (const change of changesSubmittedTogether) {
+      if (this.changesByRepo.has(change.project)) {
+        this.changesByRepo.get(change.project)!.push(change);
       } else {
-        this.changesByProject.set(change.project, [change]);
+        this.changesByRepo.set(change.project, [change]);
       }
     }
     this.requestUpdate();
   }
+
+  private async loadChangesSubmittedTogether(
+    changesInTopic?: ChangeInfo[]
+  ): Promise<ChangeInfo[]> {
+    // All changes in the topic will be submitted together, so we can use any of
+    // them for the request to getChangesSubmittedTogether as long as the topic
+    // is not empty.
+    if (!changesInTopic || changesInTopic.length === 0) {
+      return [];
+    }
+    const response = await this.restApiService.getChangesSubmittedTogether(
+      changesInTopic[0]._number,
+      ['NON_VISIBLE_CHANGES', 'CURRENT_REVISION', 'CURRENT_COMMIT']
+    );
+    return response?.changes ?? [];
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
index f214520..72e14b8 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
@@ -18,52 +18,84 @@
 import {ChangeInfo, RepoName} from '../../api/rest-api';
 import '../../test/common-test-setup-karma';
 import {createChange} from '../../test/test-data-generators';
-import {queryAll, stubRestApi} from '../../test/test-utils';
+import {mockPromise, queryAll, stubRestApi} from '../../test/test-utils';
+import {SubmittedTogetherInfo} from '../../types/common';
 import './gr-topic-tree';
 import {GrTopicTree} from './gr-topic-tree';
-import {GrTopicTreeProject} from './gr-topic-tree-project';
+import {GrTopicTreeRepo} from './gr-topic-tree-repo';
 
 const basicFixture = fixtureFromElement('gr-topic-tree');
 
-function createChangeForProject(projectName: string): ChangeInfo {
-  return {...createChange(), project: projectName as RepoName};
+const repo1Name = 'repo1' as RepoName;
+const repo2Name = 'repo2' as RepoName;
+const repo3Name = 'repo3' as RepoName;
+
+function createChangeForRepo(repoName: string): ChangeInfo {
+  return {...createChange(), project: repoName as RepoName};
 }
 
 suite('gr-topic-tree tests', () => {
   let element: GrTopicTree;
-  const project1Changes = [
-    createChangeForProject('project1'),
-    createChangeForProject('project1'),
+  const repo1ChangeOutsideTopic = createChangeForRepo(repo1Name);
+  const repo1ChangesInTopic = [
+    createChangeForRepo(repo1Name),
+    createChangeForRepo(repo1Name),
   ];
-  const project2Changes = [
-    createChangeForProject('project2'),
-    createChangeForProject('project2'),
+  const repo2ChangesInTopic = [
+    createChangeForRepo(repo2Name),
+    createChangeForRepo(repo2Name),
   ];
-  const project3Changes = [
-    createChangeForProject('project3'),
-    createChangeForProject('project3'),
+  const repo3ChangesInTopic = [
+    createChangeForRepo(repo3Name),
+    createChangeForRepo(repo3Name),
   ];
 
   setup(async () => {
-    stubRestApi('getChanges')
-      .withArgs(undefined, 'topic:myTopic')
-      .resolves([...project1Changes, ...project2Changes, ...project3Changes]);
+    stubRestApi('getChangesWithSameTopic')
+      .withArgs('myTopic')
+      .resolves([
+        ...repo1ChangesInTopic,
+        ...repo2ChangesInTopic,
+        ...repo3ChangesInTopic,
+      ]);
+    const changesSubmittedTogetherPromise =
+      mockPromise<SubmittedTogetherInfo>();
+    stubRestApi('getChangesSubmittedTogether').returns(
+      changesSubmittedTogetherPromise
+    );
     element = basicFixture.instantiate();
     element.topicName = 'myTopic';
+
+    // The first update will trigger the data to be loaded. The second update
+    // will be rendering the loaded data.
+    await element.updateComplete;
+    changesSubmittedTogetherPromise.resolve({
+      changes: [
+        ...repo1ChangesInTopic,
+        repo1ChangeOutsideTopic,
+        ...repo2ChangesInTopic,
+        ...repo3ChangesInTopic,
+      ],
+      non_visible_changes: 0,
+    });
+    await changesSubmittedTogetherPromise;
     await element.updateComplete;
   });
 
-  test('groups changes by project', () => {
-    const projectSections = queryAll<GrTopicTreeProject>(
+  test('groups changes by repo', () => {
+    const repoSections = queryAll<GrTopicTreeRepo>(
       element,
-      'gr-topic-tree-project'
+      'gr-topic-tree-repo'
     );
-    assert.lengthOf(projectSections, 3);
-    assert.equal(projectSections[0].projectName, 'project1');
-    assert.sameMembers(projectSections[0].changes!, project1Changes);
-    assert.equal(projectSections[1].projectName, 'project2');
-    assert.sameMembers(projectSections[1].changes!, project2Changes);
-    assert.equal(projectSections[2].projectName, 'project3');
-    assert.sameMembers(projectSections[2].changes!, project3Changes);
+    assert.lengthOf(repoSections, 3);
+    assert.equal(repoSections[0].repoName, repo1Name);
+    assert.sameMembers(repoSections[0].changes!, [
+      ...repo1ChangesInTopic,
+      repo1ChangeOutsideTopic,
+    ]);
+    assert.equal(repoSections[1].repoName, repo2Name);
+    assert.sameMembers(repoSections[1].changes!, repo2ChangesInTopic);
+    assert.equal(repoSections[2].repoName, repo3Name);
+    assert.sameMembers(repoSections[2].changes!, repo3ChangesInTopic);
   });
 });
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 229bce3..38ed276 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -15,10 +15,11 @@
  * limitations under the License.
  */
 
-import {getAppContext} from '../services/app-context';
+import {create, Registry, Finalizable} from '../services/registry';
+import {AppContext} from '../services/app-context';
+import {AuthService} from '../services/gr-auth/gr-auth';
 import {FlagsService} from '../services/flags/flags';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {AuthService} from '../services/gr-auth/gr-auth';
 
 class MockFlagsService implements FlagsService {
   isEnabled() {
@@ -63,15 +64,47 @@
 // Setup mocks for appContext.
 // This is a temporary solution
 // TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
-  function setMock(serviceName: string, setupMock: unknown) {
-    Object.defineProperty(getAppContext(), serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('flagsService', new MockFlagsService());
-  setMock('reportingService', grReportingMock);
-  setMock('authService', new MockAuthService());
+export function createDiffAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
+    authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
+    reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+    eventEmitter: (_ctx: Partial<AppContext>) => {
+      throw new Error('eventEmitter is not implemented');
+    },
+    restApiService: (_ctx: Partial<AppContext>) => {
+      throw new Error('restApiService is not implemented');
+    },
+    changeModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('changeModel is not implemented');
+    },
+    commentsModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('commentsModel is not implemented');
+    },
+    checksModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('checksModel is not implemented');
+    },
+    jsApiService: (_ctx: Partial<AppContext>) => {
+      throw new Error('jsApiService is not implemented');
+    },
+    storageService: (_ctx: Partial<AppContext>) => {
+      throw new Error('storageService is not implemented');
+    },
+    configModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('configModel is not implemented');
+    },
+    userModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('userModel is not implemented');
+    },
+    routerModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('routerModel is not implemented');
+    },
+    shortcutsService: (_ctx: Partial<AppContext>) => {
+      throw new Error('shortcutsService is not implemented');
+    },
+    browserModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('browserModel is not implemented');
+    },
+  };
+  return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
index 695b16e..bb46484 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
@@ -16,15 +16,11 @@
  */
 
 import '../test/common-test-setup-karma.js';
-import {getAppContext} from '../services/app-context.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-suite('gr diff app context initializer tests', () => {
-  setup(() => {
-    initDiffAppContext();
-  });
+import {createDiffAppContext} from './gr-diff-app-context-init.js';
 
+suite('gr diff app context initializer tests', () => {
   test('all services initialized and are singletons', () => {
-    const appContext = getAppContext();
+    const appContext = createDiffAppContext();
     Object.keys(appContext).forEach(serviceName => {
       const service = appContext[serviceName];
       assert.isNotNull(service);
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 422667a4..64ef214 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -28,11 +28,12 @@
 import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
 import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
 import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+import {injectAppContext} from '../services/app-context';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
-initDiffAppContext();
+injectAppContext(createDiffAppContext());
 // Setup global variables for existing usages of this component
 window.grdiff = {
   GrAnnotation,
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 47d5f03..fbf5b0f 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -20,23 +20,25 @@
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
-import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from './user/user-model';
+import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {assertIsDefined} from '../utils/common-util';
+import {ConfigModel} from './config/config-model';
 
 /**
  * The AppContext lazy initializator for all services
  */
 export function createAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (ctx: Partial<AppContext>) => {
@@ -51,38 +53,66 @@
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
       assertIsDefined(ctx.flagsService, 'flagsService)');
-      return new GrRestApiInterface(ctx.authService!, ctx.flagsService!);
+      return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
     },
-    changeService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+    changeModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ChangeModel(routerModel, restApiService);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
+    commentsModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      assertIsDefined(reportingService, 'reportingService');
+      return new CommentsModel(
+        routerModel,
+        changeModel,
+        restApiService,
+        reportingService
+      );
     },
-    checksService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksService(ctx.reportingService!);
+    checksModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(reportingService, 'reportingService');
+      return new ChecksModel(routerModel, changeModel, reportingService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new GrJsApiInterface(ctx.reportingService!);
+      const reportingService = ctx.reportingService;
+      assertIsDefined(reportingService, 'reportingService');
+      return new GrJsApiInterface(reportingService!);
     },
     storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
-    configService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigService(ctx.restApiService!);
+    configModel: (ctx: Partial<AppContext>) => {
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ConfigModel(changeModel, restApiService);
     },
-    userService: (ctx: Partial<AppContext>) => {
+    userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserService(ctx.restApiService!);
+      return new UserModel(ctx.restApiService!);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel, ctx.reportingService!);
     },
-    browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+    browserModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
+      return new BrowserModel(ctx.userModel!);
+    },
   };
   return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 87a18ef..367fee7 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,29 +20,31 @@
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from './user/user-model';
+import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
+import {ConfigModel} from './config/config-model';
 
 export interface AppContext {
+  routerModel: RouterModel;
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
-  commentsService: CommentsService;
-  checksService: ChecksService;
+  changeModel: ChangeModel;
+  commentsModel: CommentsModel;
+  checksModel: ChecksModel;
   jsApiService: JsApiService;
   storageService: StorageService;
-  configService: ConfigService;
-  userService: UserService;
+  configModel: ConfigModel;
+  userModel: UserModel;
   browserModel: BrowserModel;
   shortcutsService: ShortcutsService;
 }
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
index b15091a..a675cdd 100644
--- a/polygerrit-ui/app/services/browser/browser-model.ts
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -17,8 +17,8 @@
 import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
 import {distinctUntilChanged, map} from 'rxjs/operators';
 import {Finalizable} from '../registry';
-import {preferenceDiffViewMode$} from '../user/user-model';
 import {DiffViewMode} from '../../api/diff';
+import {UserModel} from '../user/user-model';
 
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -42,7 +42,7 @@
     return this.privateState$;
   }
 
-  constructor() {
+  constructor(readonly userModel: UserModel) {
     const screenWidth$ = this.privateState$.pipe(
       map(
         state =>
@@ -55,7 +55,7 @@
     // the user model.
     this.diffViewMode$ = combineLatest([
       screenWidth$,
-      preferenceDiffViewMode$,
+      userModel.preferenceDiffViewMode$,
     ]).pipe(
       map(([isScreenTooSmall, preferenceDiffViewMode]) => {
         if (isScreenTooSmall) return DiffViewMode.UNIFIED;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 84e55f9..7055447 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -15,22 +15,48 @@
  * limitations under the License.
  */
 
-import {PatchSetNum} from '../../types/common';
-import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {
+  combineLatest,
+  from,
+  fromEvent,
+  BehaviorSubject,
+  Observable,
+  Subscription,
+} from 'rxjs';
 import {
   map,
   filter,
   withLatestFrom,
   distinctUntilChanged,
+  startWith,
+  switchMap,
 } from 'rxjs/operators';
-import {routerPatchNum$, routerState$} from '../router/router-model';
+import {RouterModel} from '../router/router-model';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
 } from '../../utils/patch-set-util';
 import {ParsedChangeInfo} from '../../types/types';
 
-interface ChangeState {
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
+
+export enum LoadingStatus {
+  NOT_LOADED = 'NOT_LOADED',
+  LOADING = 'LOADING',
+  RELOADING = 'RELOADING',
+  LOADED = 'LOADED',
+}
+
+export interface ChangeState {
+  /**
+   * If `change` is undefined, this must be either NOT_LOADED or LOADING.
+   * If `change` is defined, this must be either LOADED or RELOADING.
+   */
+  loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
   /**
    * The name of the file user is viewing in the diff view mode. File path is
@@ -42,115 +68,196 @@
 
 // TODO: Figure out how to best enforce immutability of all states. Use Immer?
 // Use DeepReadOnly?
-const initialState: ChangeState = {};
+const initialState: ChangeState = {
+  loadingStatus: LoadingStatus.NOT_LOADED,
+};
 
-const privateState$ = new BehaviorSubject(initialState);
+export class ChangeModel implements Finalizable {
+  private readonly privateState$ = new BehaviorSubject(initialState);
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
+  public readonly changeState$: Observable<ChangeState> =
+    this.privateState$.asObservable();
 
-export function _testOnly_setState(state: ChangeState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const changeState$: Observable<ChangeState> = privateState$;
-
-// Must only be used by the change service or whatever is in control of this
-// model.
-export function updateStateChange(change?: ParsedChangeInfo) {
-  const current = privateState$.getValue();
-  // We want to make it easy for subscribers to react to change changes, so we
-  // are explicitly emitting an 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});
-}
-
-export function updateStatePath(diffPath?: string) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, diffPath});
-}
-
-/**
- * If you depend on both, router and change state, then you want to filter out
- * inconsistent state, e.g. router changeNum already updated, change not yet
- * reset to undefined.
- */
-export const changeAndRouterConsistent$ = combineLatest([
-  routerState$,
-  changeState$,
-]).pipe(
-  filter(([routerState, changeState]) => {
-    const changeNum = changeState.change?._number;
-    const routerChangeNum = routerState.changeNum;
-    return changeNum === undefined || changeNum === routerChangeNum;
-  }),
-  distinctUntilChanged()
-);
-
-export const change$ = changeState$.pipe(
-  map(changeState => changeState.change),
-  distinctUntilChanged()
-);
-
-export const changeLoading$ = change$.pipe(
-  map(change => change === undefined),
-  distinctUntilChanged()
-);
-
-export const diffPath$ = changeState$.pipe(
-  map(changeState => changeState?.diffPath),
-  distinctUntilChanged()
-);
-
-export const changeNum$ = change$.pipe(
-  map(change => change?._number),
-  distinctUntilChanged()
-);
-
-export const repo$ = change$.pipe(
-  map(change => change?.project),
-  distinctUntilChanged()
-);
-
-export const labels$ = change$.pipe(
-  map(change => change?.labels),
-  distinctUntilChanged()
-);
-
-export const latestPatchNum$ = change$.pipe(
-  map(change => computeLatestPatchNum(computeAllPatchSets(change))),
-  distinctUntilChanged()
-);
-
-/**
- * Emits the current patchset number. If the route does not define the current
- * patchset num, then this selector waits for the change to be defined and
- * returns the number of the latest patchset.
- *
- * Note that this selector can emit a patchNum without the change being
- * available!
- */
-export const currentPatchNum$: Observable<PatchSetNum | undefined> =
-  changeAndRouterConsistent$.pipe(
-    withLatestFrom(routerPatchNum$, latestPatchNum$),
-    map(
-      ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
-    ),
-    distinctUntilChanged()
+  public readonly change$ = select(
+    this.privateState$,
+    changeState => changeState.change
   );
+
+  public readonly changeLoadingStatus$ = select(
+    this.privateState$,
+    changeState => changeState.loadingStatus
+  );
+
+  public readonly diffPath$ = select(
+    this.privateState$,
+    changeState => changeState?.diffPath
+  );
+
+  public readonly changeNum$ = select(this.change$, change => change?._number);
+
+  public readonly repo$ = select(this.change$, change => change?.project);
+
+  public readonly labels$ = select(this.change$, change => change?.labels);
+
+  public readonly latestPatchNum$ = select(this.change$, change =>
+    computeLatestPatchNum(computeAllPatchSets(change))
+  );
+
+  /**
+   * Emits the current patchset number. If the route does not define the current
+   * patchset num, then this selector waits for the change to be defined and
+   * returns the number of the latest patchset.
+   *
+   * Note that this selector can emit a patchNum without the change being
+   * available!
+   */
+  public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+    /**
+     * If you depend on both, router and change state, then you want to filter
+     * out inconsistent state, e.g. router changeNum already updated, change not
+     * yet reset to undefined.
+     */
+    combineLatest([this.routerModel.routerState$, this.changeState$])
+      .pipe(
+        filter(([routerState, changeState]) => {
+          const changeNum = changeState.change?._number;
+          const routerChangeNum = routerState.changeNum;
+          return changeNum === undefined || changeNum === routerChangeNum;
+        }),
+        distinctUntilChanged()
+      )
+      .pipe(
+        withLatestFrom(this.routerModel.routerPatchNum$, this.latestPatchNum$),
+        map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
+        distinctUntilChanged()
+      );
+
+  private subscriptions: Subscription[] = [];
+
+  // For usage in `combineLatest` we need `startWith` such that reload$ has an
+  // initial value.
+  private readonly reload$: Observable<unknown> = fromEvent(
+    document,
+    'reload'
+  ).pipe(startWith(undefined));
+
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly restApiService: RestApiService
+  ) {
+    this.subscriptions = [
+      combineLatest([this.routerModel.routerChangeNum$, this.reload$])
+        .pipe(
+          map(([changeNum, _]) => changeNum),
+          switchMap(changeNum => {
+            if (changeNum !== undefined) this.updateStateLoading(changeNum);
+            return from(this.restApiService.getChangeDetail(changeNum));
+          })
+        )
+        .subscribe(change => {
+          // The change service is currently a singleton, so we have to be
+          // careful to avoid situations where the application state is
+          // partially set for the old change where the user is coming from,
+          // and partially for the new change where the user is navigating to.
+          // So setting the change explicitly to undefined when the user
+          // moves away from diff and change pages (changeNum === undefined)
+          // helps with that.
+          this.updateStateChange(change ?? undefined);
+        }),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+
+  // Temporary workaround until path is derived in the model itself.
+  updatePath(diffPath?: string) {
+    const current = this.getState();
+    this.setState({...current, diffPath});
+  }
+
+  /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.getState().change;
+  }
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
+
+  /**
+   * Called when change detail loading is initiated.
+   *
+   * If the change number matches the current change in the state, then
+   * this is a reload. If not, then we not just want to set the state to
+   * LOADING instead of RELOADING, but we also want to set the change to
+   * undefined right away. Otherwise components could see inconsistent state:
+   * a new change number, but an old change.
+   */
+  private updateStateLoading(changeNum: NumericChangeId) {
+    const current = this.getState();
+    const reloading = current.change?._number === changeNum;
+    this.setState({
+      ...current,
+      change: reloading ? current.change : undefined,
+      loadingStatus: reloading
+        ? LoadingStatus.RELOADING
+        : LoadingStatus.LOADING,
+    });
+  }
+
+  // Private but used in tests.
+  updateStateChange(change?: ParsedChangeInfo) {
+    const current = this.getState();
+    this.setState({
+      ...current,
+      change,
+      loadingStatus:
+        change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+    });
+  }
+
+  getState(): ChangeState {
+    return this.privateState$.getValue();
+  }
+
+  // Private but used in tests
+  setState(state: ChangeState) {
+    this.privateState$.next(state);
+  }
+}
diff --git a/polygerrit-ui/app/services/change/change-model_test.ts b/polygerrit-ui/app/services/change/change-model_test.ts
new file mode 100644
index 0000000..0fb9712
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../../test/test-data-generators';
+import {mockPromise, stubRestApi, waitUntil} from '../../test/test-utils';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../app-context';
+import {GerritView} from '../router/router-model';
+import {ChangeState, LoadingStatus} from './change-model';
+import {ChangeModel} from './change-model';
+
+suite('change service tests', () => {
+  let changeModel: ChangeModel;
+  let knownChange: ParsedChangeInfo;
+  const testCompleted = new Subject<void>();
+  setup(() => {
+    changeModel = new ChangeModel(
+      getAppContext().routerModel,
+      getAppContext().restApiService
+    );
+    knownChange = {
+      ...createChange(),
+      revisions: {
+        sha1: {
+          ...createRevision(1),
+          description: 'patch 1',
+          _number: 1 as PatchSetNum,
+        },
+        sha2: {
+          ...createRevision(2),
+          description: 'patch 2',
+          _number: 2 as PatchSetNum,
+        },
+      },
+      status: ChangeStatus.NEW,
+      current_revision: 'abc' as CommitId,
+      messages: [],
+    };
+  });
+
+  teardown(() => {
+    testCompleted.next();
+    changeModel.finalize();
+  });
+
+  test('load a change', async () => {
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState | undefined = {
+      loadingStatus: LoadingStatus.NOT_LOADED,
+    };
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
+
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 0);
+    assert.isUndefined(state?.change);
+
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 1);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(knownChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 1);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('reload a change', async () => {
+    // setting up a loaded change
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState | undefined = {
+      loadingStatus: LoadingStatus.NOT_LOADED,
+    };
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+    // Reloading same change
+    document.dispatchEvent(new CustomEvent('reload'));
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.RELOADING);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+
+    promise.resolve(knownChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('navigating to another change', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState | undefined = {
+      loadingStatus: LoadingStatus.NOT_LOADED,
+    };
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+    // Navigating to other change
+
+    const otherChange: ParsedChangeInfo = {
+      ...knownChange,
+      _number: 123 as NumericChangeId,
+    };
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: otherChange._number,
+    });
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(otherChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, otherChange);
+  });
+
+  test('navigating to dashboard', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState | undefined = {
+      loadingStatus: LoadingStatus.NOT_LOADED,
+    };
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+    // Navigating to dashboard
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(undefined);
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: undefined,
+    });
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    // Navigating back from dashboard to change page
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(knownChange);
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 3);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('changeModel.fetchChangeUpdates on latest', async () => {
+    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates not on latest', async () => {
+    const actualChange = {
+      ...knownChange,
+      revisions: {
+        ...knownChange.revisions,
+        sha3: {
+          ...createRevision(3),
+          description: 'patch 3',
+          _number: 3 as PatchSetNum,
+        },
+      },
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isFalse(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new status', async () => {
+    const actualChange = {
+      ...knownChange,
+      status: ChangeStatus.MERGED,
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.equal(result.newStatus, ChangeStatus.MERGED);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new messages', async () => {
+    const actualChange = {
+      ...knownChange,
+      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.deepEqual(result.newMessages, {
+      ...createChangeMessageInfo(),
+      message: 'blah blah',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
deleted file mode 100644
index ff417b2..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {from, Subscription} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
-import {routerChangeNum$} from '../router/router-model';
-import {change$, updateStateChange, updateStatePath} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeInfo} from '../../types/common';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ChangeService implements Finalizable {
-  private change?: ParsedChangeInfo;
-
-  private readonly subscriptions: Subscription[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    // 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.
-    this.subscriptions.push(
-      routerChangeNum$
-        .pipe(
-          // The change service is currently a singleton, so we have to be
-          // careful to avoid situations where the application state is
-          // partially set for the old change where the user is coming from,
-          // and partially for the new change where the user is navigating to.
-          // So setting the change explicitly to undefined when the user
-          // moves away from diff and change pages (changeNum === undefined)
-          // helps with that.
-          switchMap(changeNum =>
-            from(this.restApiService.getChangeDetail(changeNum))
-          )
-        )
-        .subscribe(change => {
-          updateStateChange(change ?? undefined);
-        })
-    );
-    this.subscriptions.push(
-      change$.subscribe(change => {
-        this.change = change;
-      })
-    );
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  // Temporary workaround until path is derived in the model itself.
-  updatePath(path?: string) {
-    updateStatePath(path);
-  }
-
-  /**
-   * Typically you would just subscribe to change$ yourself to get updates. But
-   * sometimes it is nice to also be able to get the current ChangeInfo on
-   * demand. So here it is for your convenience.
-   */
-  getChange() {
-    return this.change;
-  }
-
-  /**
-   * Check whether there is no newer patch than the latest patch that was
-   * available when this change was loaded.
-   *
-   * @return A promise that yields true if the latest patch
-   *     has been loaded, and false if a newer patch has been uploaded in the
-   *     meantime. The promise is rejected on network error.
-   */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
-  }
-}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
deleted file mode 100644
index 094362b..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
-import {
-  createChange,
-  createChangeMessageInfo,
-  createRevision,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil} from '../../test/test-utils';
-import {CommitId, PatchSetNum} from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
-import {getAppContext} from '../app-context';
-import {
-  GerritView,
-  _testOnly_setState as setRouterState,
-} from '../router/router-model';
-import {ChangeService} from './change-service';
-
-suite('change service tests', () => {
-  let changeService: ChangeService;
-  let knownChange: ParsedChangeInfo;
-  setup(() => {
-    changeService = new ChangeService(getAppContext().restApiService);
-    knownChange = {
-      ...createChange(),
-      revisions: {
-        sha1: {
-          ...createRevision(1),
-          description: 'patch 1',
-          _number: 1 as PatchSetNum,
-        },
-        sha2: {
-          ...createRevision(2),
-          description: 'patch 2',
-          _number: 2 as PatchSetNum,
-        },
-      },
-      status: ChangeStatus.NEW,
-      current_revision: 'abc' as CommitId,
-      messages: [],
-    };
-  });
-
-  teardown(() => {
-    changeService.finalize();
-  });
-
-  test('changeService switching changes', async () => {
-    const change = knownChange;
-    const stub = stubRestApi('getChangeDetail').returns(
-      Promise.resolve(change)
-    );
-
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
-    waitUntil(() => changeService.getChange() === knownChange);
-    assert.equal(stub.callCount, 1);
-
-    setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
-    waitUntil(() => changeService.getChange() === undefined);
-    assert.equal(stub.callCount, 2);
-
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
-    waitUntil(() => changeService.getChange() === knownChange);
-    assert.equal(stub.callCount, 3);
-  });
-
-  test('changeService.fetchChangeUpdates on latest', async () => {
-    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates not on latest', async () => {
-    const actualChange = {
-      ...knownChange,
-      revisions: {
-        ...knownChange.revisions,
-        sha3: {
-          ...createRevision(3),
-          description: 'patch 3',
-          _number: 3 as PatchSetNum,
-        },
-      },
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isFalse(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new status', async () => {
-    const actualChange = {
-      ...knownChange,
-      status: ChangeStatus.MERGED,
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.equal(result.newStatus, ChangeStatus.MERGED);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new messages', async () => {
-    const actualChange = {
-      ...knownChange,
-      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.deepEqual(result.newMessages, {
-      ...createChangeMessageInfo(),
-      message: 'blah blah',
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-fakes.ts b/polygerrit-ui/app/services/checks/checks-fakes.ts
new file mode 100644
index 0000000..09cd2e7
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-fakes.ts
@@ -0,0 +1,418 @@
+/**
+ * @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 {
+  Action,
+  Category,
+  Link,
+  LinkIcon,
+  RunStatus,
+  TagColor,
+} from '../../api/checks';
+import {CheckRun} from './checks-model';
+
+// TODO(brohlfs): Eventually these fakes should be removed. But they have proven
+// to be super convenient for testing, debugging and demoing, so I would like to
+// keep them around for a few quarters. Maybe remove by EOY 2022?
+
+export const fakeRun0: CheckRun = {
+  pluginName: 'f0',
+  internalRunId: 'f0',
+  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
+  labelName: 'Presubmit',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f0r0',
+      category: Category.ERROR,
+      summary: 'I would like to point out this error: 1 is not equal to 2!',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+    },
+    {
+      internalResultId: 'f0r1',
+      category: Category.ERROR,
+      summary: 'Running the mighty test has failed by crashing.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
+      actions: [
+        {
+          name: 'Ignore',
+          tooltip: 'Ignore this result',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
+        },
+        {
+          name: 'Flag',
+          tooltip: 'Flag this result as totally absolutely really not useful',
+          primary: true,
+          disabled: true,
+          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
+        },
+        {
+          name: 'Upload',
+          tooltip: 'Upload the result to the super cloud.',
+          primary: false,
+          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
+        },
+      ],
+      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
+      links: [
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
+      ],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+  pluginName: 'f1',
+  internalRunId: 'f1',
+  checkName: 'FAKE Super Check',
+  statusLink: 'https://www.google.com/',
+  patchset: 1,
+  labelName: 'Verified',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f1r0',
+      category: Category.WARNING,
+      summary: 'We think that you could improve this.',
+      message: `There is a lot to be said. A lot. I say, a lot.\n
+                So please keep reading.`,
+      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 0,
+          },
+        },
+        {
+          path: 'polygerrit-ui/app/api/checks.ts',
+          range: {
+            start_line: 5,
+            start_character: 0,
+            end_line: 7,
+            end_character: 0,
+          },
+        },
+      ],
+      links: [
+        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
+      ],
+    },
+  ],
+  status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+  pluginName: 'f2',
+  internalRunId: 'f2',
+  checkName: 'FAKE Mega Analysis',
+  statusDescription: 'This run is nearly completed, but not quite.',
+  statusLink: 'https://www.google.com/',
+  checkDescription:
+    'From what the title says you can tell that this check analyses.',
+  checkLink: 'https://www.google.com/',
+  scheduledTimestamp: new Date('2021-04-01T03:14:15'),
+  startedTimestamp: new Date('2021-04-01T04:24:25'),
+  finishedTimestamp: new Date('2021-04-01T04:44:44'),
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'More powerful run than before',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+    {
+      name: 'Monetize',
+      primary: true,
+      disabled: true,
+      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
+    },
+    {
+      name: 'Delete',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
+    },
+  ],
+  results: [
+    {
+      internalResultId: 'f2r0',
+      category: Category.INFO,
+      summary: 'This is looking a bit too large.',
+      message: `We are still looking into how large exactly. Stay tuned.
+And have a look at https://www.google.com!
+
+Or have a look at change 30000.
+Example code:
+  const constable = '';
+  var variable = '';`,
+      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+  pluginName: 'f3',
+  internalRunId: 'f3',
+  checkName: 'FAKE Critical Observations',
+  status: RunStatus.RUNNABLE,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.RUNNABLE,
+  attempt: 1,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.COMPLETED,
+  attempt: 2,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f42r0',
+      category: Category.INFO,
+      summary: 'Please eliminate all the TODOs!',
+    },
+  ],
+};
+
+export const fakeRun4_3: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.COMPLETED,
+  attempt: 3,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f43r0',
+      category: Category.ERROR,
+      summary: 'Without eliminating all the TODOs your change will break!',
+    },
+  ],
+};
+
+export const fakeRun4_4: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  checkDescription: 'Shows you the possible eliminations.',
+  checkLink: 'https://www.google.com',
+  status: RunStatus.COMPLETED,
+  statusDescription: 'Everything was eliminated already.',
+  statusLink: 'https://www.google.com',
+  attempt: 40,
+  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+  startedTimestamp: new Date('2021-04-02T04:24:25'),
+  finishedTimestamp: new Date('2021-04-02T04:25:44'),
+  isSingleAttempt: false,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f44r0',
+      category: Category.INFO,
+      summary: 'Dont be afraid. All TODOs will be eliminated.',
+      actions: [
+        {
+          name: 'Re-Run',
+          tooltip: 'More powerful run than before with a long tooltip, really.',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+        },
+      ],
+    },
+  ],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'small',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+  ],
+};
+
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+  const runs: CheckRun[] = [];
+  for (let i = from; i < to; i++) {
+    runs.push(fakeRun4CreateAttempt(i));
+  }
+  return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+  return {
+    pluginName: 'f4',
+    internalRunId: 'f4',
+    checkName: 'FAKE Elimination Long Long Long Long Long',
+    status: RunStatus.COMPLETED,
+    attempt,
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results:
+      attempt % 2 === 0
+        ? [
+            {
+              internalResultId: 'f43r0',
+              category: Category.ERROR,
+              summary:
+                'Without eliminating all the TODOs your change will break!',
+            },
+          ]
+        : [],
+  };
+}
+
+export const fakeRun4Att = [
+  fakeRun4_1,
+  fakeRun4_2,
+  fakeRun4_3,
+  ...fakeRun4CreateAttempts(5, 40),
+  fakeRun4_4,
+];
+
+export const fakeActions: Action[] = [
+  {
+    name: 'Fake Action 1',
+    primary: true,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 1',
+    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
+  },
+  {
+    name: 'Fake Action 2',
+    primary: false,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 2',
+    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
+  },
+  {
+    name: 'Fake Action 3',
+    summary: true,
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 3',
+    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
+  },
+];
+
+export const fakeLinks: Link[] = [
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 1',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
+];
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index bb6809f..134afd8 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -14,23 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {BehaviorSubject, Observable} from 'rxjs';
+import {AttemptDetail, createAttemptMap} from './checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {Finalizable} from '../registry';
+import {
+  BehaviorSubject,
+  combineLatest,
+  from,
+  Observable,
+  of,
+  Subject,
+  Subscription,
+  timer,
+} from 'rxjs';
+import {
+  catchError,
+  filter,
+  switchMap,
+  takeUntil,
+  takeWhile,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
 import {
   Action,
-  Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
   Link,
-  LinkIcon,
-  RunStatus,
-  TagColor,
+  ChangeData,
+  ChecksApiConfig,
+  ChecksProvider,
+  FetchResponse,
+  ResponseCode,
 } from '../../api/checks';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {PatchSetNumber} from '../../types/common';
-import {AttemptDetail, createAttemptMap} from './checks-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {deepEqual} from '../../utils/deep-util';
+import {ChangeModel} from '../change/change-model';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+import {Execution} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
+import {RouterModel} from '../router/router-model';
 
 /**
  * The checks model maintains the state of checks for two patchsets: the latest
@@ -83,7 +108,7 @@
 // properties. So you can just combine them with {...run, ...result}.
 export type RunResult = CheckRun & CheckResult;
 
-interface ChecksProviderState {
+export interface ChecksProviderState {
   pluginName: string;
   loading: boolean;
   /**
@@ -121,117 +146,101 @@
   };
 }
 
-const initialState: ChecksState = {
-  pluginStateLatest: {},
-  pluginStateSelected: {},
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
-
-export function _testOnly_setState(state: ChecksState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const checksState$: Observable<ChecksState> = privateState$;
-
-export const checksSelectedPatchsetNumber$ = checksState$.pipe(
-  map(state => state.patchsetNumberSelected),
-  distinctUntilChanged()
-);
-
-export const checksLatest$ = checksState$.pipe(
-  map(state => state.pluginStateLatest),
-  distinctUntilChanged()
-);
-
-export const checksSelected$ = checksState$.pipe(
-  map(state =>
-    state.patchsetNumberSelected
-      ? state.pluginStateSelected
-      : state.pluginStateLatest
-  ),
-  distinctUntilChanged()
-);
-
-export const aPluginHasRegistered$ = checksLatest$.pipe(
-  map(state => Object.keys(state).length > 0),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(
-      provider => provider.loading && provider.firstTimeLoad
-    )
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const errorMessageLatest$ = checksLatest$.pipe(
-  map(
-    state =>
-      Object.values(state).find(
-        providerState => providerState.errorMessage !== undefined
-      )?.errorMessage
-  ),
-  distinctUntilChanged()
-);
-
 export interface ErrorMessages {
   /* Maps plugin name to error message. */
   [name: string]: string;
 }
 
-export const errorMessagesLatest$ = checksLatest$.pipe(
-  map(state => {
+export class ChecksModel implements Finalizable {
+  private readonly providers: {[name: string]: ChecksProvider} = {};
+
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+  private checkToPluginMap = new Map<string, string>();
+
+  private changeNum?: NumericChangeId;
+
+  private latestPatchNum?: PatchSetNumber;
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  private readonly reloadListener: () => void;
+
+  private readonly visibilityChangeListener: () => void;
+
+  private subscriptions: Subscription[] = [];
+
+  private readonly privateState$ = new BehaviorSubject<ChecksState>({
+    pluginStateLatest: {},
+    pluginStateSelected: {},
+  });
+
+  public checksState$: Observable<ChecksState> =
+    this.privateState$.asObservable();
+
+  public checksSelectedPatchsetNumber$ = select(
+    this.checksState$,
+    state => state.patchsetNumberSelected
+  );
+
+  public checksLatest$ = select(
+    this.checksState$,
+    state => state.pluginStateLatest
+  );
+
+  public checksSelected$ = select(this.checksState$, state =>
+    state.patchsetNumberSelected
+      ? state.pluginStateSelected
+      : state.pluginStateLatest
+  );
+
+  public aPluginHasRegistered$ = select(
+    this.checksLatest$,
+    state => Object.keys(state).length > 0
+  );
+
+  public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
+    Object.values(state).some(
+      provider => provider.loading && provider.firstTimeLoad
+    )
+  );
+
+  public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
+    Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public someProvidersAreLoadingSelected$ = select(
+    this.checksSelected$,
+    state => Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public errorMessageLatest$ = select(
+    this.checksLatest$,
+
+    state =>
+      Object.values(state).find(
+        providerState => providerState.errorMessage !== undefined
+      )?.errorMessage
+  );
+
+  public errorMessagesLatest$ = select(this.checksLatest$, state => {
     const errorMessages: ErrorMessages = {};
     for (const providerState of Object.values(state)) {
       if (providerState.errorMessage === undefined) continue;
       errorMessages[providerState.pluginName] = providerState.errorMessage;
     }
     return errorMessages;
-  }),
-  distinctUntilChanged(deepEqual)
-);
+  });
 
-export const loginCallbackLatest$ = checksLatest$.pipe(
-  map(
+  public loginCallbackLatest$ = select(
+    this.checksLatest$,
     state =>
       Object.values(state).find(
         providerState => providerState.loginCallback !== undefined
       )?.loginCallback
-  ),
-  distinctUntilChanged()
-);
+  );
 
-export const topLevelActionsLatest$ = checksLatest$.pipe(
-  map(state =>
+  public topLevelActionsLatest$ = select(this.checksLatest$, state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
         ...allActions,
@@ -239,12 +248,9 @@
       ],
       []
     )
-  ),
-  distinctUntilChanged<Action[]>(deepEqual)
-);
+  );
 
-export const topLevelActionsSelected$ = checksSelected$.pipe(
-  map(state =>
+  public topLevelActionsSelected$ = select(this.checksSelected$, state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
         ...allActions,
@@ -252,12 +258,9 @@
       ],
       []
     )
-  ),
-  distinctUntilChanged<Action[]>(deepEqual)
-);
+  );
 
-export const topLevelLinksSelected$ = checksSelected$.pipe(
-  map(state =>
+  public topLevelLinksSelected$ = select(this.checksSelected$, state =>
     Object.values(state).reduce(
       (allLinks: Link[], providerState: ChecksProviderState) => [
         ...allLinks,
@@ -265,12 +268,9 @@
       ],
       []
     )
-  ),
-  distinctUntilChanged<Link[]>(deepEqual)
-);
+  );
 
-export const allRunsLatestPatchset$ = checksLatest$.pipe(
-  map(state =>
+  public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
         ...allRuns,
@@ -278,12 +278,9 @@
       ],
       []
     )
-  ),
-  distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+  );
 
-export const allRunsSelectedPatchset$ = checksSelected$.pipe(
-  map(state =>
+  public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
         ...allRuns,
@@ -291,16 +288,14 @@
       ],
       []
     )
-  ),
-  distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+  );
 
-export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
-  map(runs => runs.filter(run => run.isLatestAttempt))
-);
+  public allRunsLatestPatchsetLatestAttempt$ = select(
+    this.allRunsLatestPatchset$,
+    runs => runs.filter(run => run.isLatestAttempt)
+  );
 
-export const checkToPluginMap$ = checksLatest$.pipe(
-  map(state => {
+  public checkToPluginMap$ = select(this.checksLatest$, state => {
     const map = new Map<string, string>();
     for (const [pluginName, providerState] of Object.entries(state)) {
       for (const run of providerState.runs) {
@@ -308,11 +303,9 @@
       }
     }
     return map;
-  })
-);
+  });
 
-export const allResultsSelected$ = checksSelected$.pipe(
-  map(state =>
+  public allResultsSelected$ = select(this.checksSelected$, state =>
     Object.values(state)
       .reduce(
         (allResults: CheckResult[], providerState: ChecksProviderState) => [
@@ -326,569 +319,441 @@
         []
       )
       .filter(r => r !== undefined)
-  )
-);
+  );
 
-// Must only be used by the checks service or whatever is in control of this
-// model.
-export function updateStateSetProvider(
-  pluginName: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    pluginName,
-    loading: false,
-    firstTimeLoad: true,
-    runs: [],
-    actions: [],
-    links: [],
-  };
-  privateState$.next(nextState);
-}
-
-// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
-//  They are just making it easier to develop the UI and always see all the
-//  different types/states of runs and results.
-
-export const fakeRun0: CheckRun = {
-  pluginName: 'f0',
-  internalRunId: 'f0',
-  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
-  labelName: 'Presubmit',
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f0r0',
-      category: Category.ERROR,
-      summary: 'I would like to point out this error: 1 is not equal to 2!',
-      links: [
-        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
-      ],
-      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
-    },
-    {
-      internalResultId: 'f0r1',
-      category: Category.ERROR,
-      summary: 'Running the mighty test has failed by crashing.',
-      message: 'Btw, 1 is also not equal to 3. Did you know?',
-      actions: [
-        {
-          name: 'Ignore',
-          tooltip: 'Ignore this result',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
-        },
-        {
-          name: 'Flag',
-          tooltip: 'Flag this result as totally absolutely really not useful',
-          primary: true,
-          disabled: true,
-          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
-        },
-        {
-          name: 'Upload',
-          tooltip: 'Upload the result to the super cloud.',
-          primary: false,
-          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
-        },
-      ],
-      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
-      links: [
-        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
-      ],
-    },
-  ],
-  status: RunStatus.COMPLETED,
-};
-
-export const fakeRun1: CheckRun = {
-  pluginName: 'f1',
-  internalRunId: 'f1',
-  checkName: 'FAKE Super Check',
-  statusLink: 'https://www.google.com/',
-  patchset: 1,
-  labelName: 'Verified',
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f1r0',
-      category: Category.WARNING,
-      summary: 'We think that you could improve this.',
-      message: `There is a lot to be said. A lot. I say, a lot.\n
-                So please keep reading.`,
-      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
-      codePointers: [
-        {
-          path: '/COMMIT_MSG',
-          range: {
-            start_line: 10,
-            start_character: 0,
-            end_line: 10,
-            end_character: 0,
-          },
-        },
-        {
-          path: 'polygerrit-ui/app/api/checks.ts',
-          range: {
-            start_line: 5,
-            start_character: 0,
-            end_line: 7,
-            end_character: 0,
-          },
-        },
-      ],
-      links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'look at this',
-          icon: LinkIcon.IMAGE,
-        },
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'not at this',
-          icon: LinkIcon.IMAGE,
-        },
-      ],
-    },
-  ],
-  status: RunStatus.RUNNING,
-};
-
-export const fakeRun2: CheckRun = {
-  pluginName: 'f2',
-  internalRunId: 'f2',
-  checkName: 'FAKE Mega Analysis',
-  statusDescription: 'This run is nearly completed, but not quite.',
-  statusLink: 'https://www.google.com/',
-  checkDescription:
-    'From what the title says you can tell that this check analyses.',
-  checkLink: 'https://www.google.com/',
-  scheduledTimestamp: new Date('2021-04-01T03:14:15'),
-  startedTimestamp: new Date('2021-04-01T04:24:25'),
-  finishedTimestamp: new Date('2021-04-01T04:44:44'),
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  actions: [
-    {
-      name: 'Re-Run',
-      tooltip: 'More powerful run than before',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-    {
-      name: 'Monetize',
-      primary: true,
-      disabled: true,
-      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
-    },
-    {
-      name: 'Delete',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
-    },
-  ],
-  results: [
-    {
-      internalResultId: 'f2r0',
-      category: Category.INFO,
-      summary: 'This is looking a bit too large.',
-      message: `We are still looking into how large exactly. Stay tuned.
-And have a look at https://www.google.com!
-
-Or have a look at change 30000.
-Example code:
-  const constable = '';
-  var variable = '';`,
-      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
-    },
-  ],
-  status: RunStatus.COMPLETED,
-};
-
-export const fakeRun3: CheckRun = {
-  pluginName: 'f3',
-  internalRunId: 'f3',
-  checkName: 'FAKE Critical Observations',
-  status: RunStatus.RUNNABLE,
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-};
-
-export const fakeRun4_1: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.RUNNABLE,
-  attempt: 1,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-};
-
-export const fakeRun4_2: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.COMPLETED,
-  attempt: 2,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f42r0',
-      category: Category.INFO,
-      summary: 'Please eliminate all the TODOs!',
-    },
-  ],
-};
-
-export const fakeRun4_3: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.COMPLETED,
-  attempt: 3,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f43r0',
-      category: Category.ERROR,
-      summary: 'Without eliminating all the TODOs your change will break!',
-    },
-  ],
-};
-
-export const fakeRun4_4: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  checkDescription: 'Shows you the possible eliminations.',
-  checkLink: 'https://www.google.com',
-  status: RunStatus.COMPLETED,
-  statusDescription: 'Everything was eliminated already.',
-  statusLink: 'https://www.google.com',
-  attempt: 40,
-  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
-  startedTimestamp: new Date('2021-04-02T04:24:25'),
-  finishedTimestamp: new Date('2021-04-02T04:25:44'),
-  isSingleAttempt: false,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f44r0',
-      category: Category.INFO,
-      summary: 'Dont be afraid. All TODOs will be eliminated.',
-      actions: [
-        {
-          name: 'Re-Run',
-          tooltip: 'More powerful run than before with a long tooltip, really.',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-        },
-      ],
-    },
-  ],
-  actions: [
-    {
-      name: 'Re-Run',
-      tooltip: 'small',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-  ],
-};
-
-export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
-  const runs: CheckRun[] = [];
-  for (let i = from; i < to; i++) {
-    runs.push(fakeRun4CreateAttempt(i));
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly changeModel: ChangeModel,
+    readonly reporting: ReportingService
+  ) {
+    this.subscriptions = [
+      this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+      this.checkToPluginMap$.subscribe(map => {
+        this.checkToPluginMap = map;
+      }),
+      combineLatest([
+        this.routerModel.routerPatchNum$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs as PatchSetNumber);
+        } else {
+          this.setPatchset(latestPs);
+        }
+      }),
+    ];
+    this.visibilityChangeListener = () => {
+      this.documentVisibilityChange$.next(undefined);
+    };
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    this.reloadListener = () => this.reloadAll();
+    document.addEventListener('reload', this.reloadListener);
   }
-  return runs;
-}
 
-export function fakeRun4CreateAttempt(attempt: number): CheckRun {
-  return {
-    pluginName: 'f4',
-    internalRunId: 'f4',
-    checkName: 'FAKE Elimination Long Long Long Long Long',
-    status: RunStatus.COMPLETED,
-    attempt,
-    isSingleAttempt: false,
-    isLatestAttempt: false,
-    attemptDetails: [],
-    results:
-      attempt % 2 === 0
-        ? [
-            {
-              internalResultId: 'f43r0',
-              category: Category.ERROR,
-              summary:
-                'Without eliminating all the TODOs your change will break!',
-            },
-          ]
-        : [],
-  };
-}
-
-export const fakeRun4Att = [
-  fakeRun4_1,
-  fakeRun4_2,
-  fakeRun4_3,
-  ...fakeRun4CreateAttempts(5, 40),
-  fakeRun4_4,
-];
-
-export const fakeActions: Action[] = [
-  {
-    name: 'Fake Action 1',
-    primary: true,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 1',
-    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
-  },
-  {
-    name: 'Fake Action 2',
-    primary: false,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 2',
-    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
-  },
-  {
-    name: 'Fake Action 3',
-    summary: true,
-    primary: false,
-    tooltip: 'Tooltip for Fake Action 3',
-    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
-  },
-];
-
-export const fakeLinks: Link[] = [
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 1',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 2',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Link 1',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Fake Link 2',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Code Link',
-    icon: LinkIcon.CODE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Image Link',
-    icon: LinkIcon.IMAGE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Help Link',
-    icon: LinkIcon.HELP_PAGE,
-  },
-];
-
-export function getPluginState(
-  state: ChecksState,
-  patchset: ChecksPatchset = ChecksPatchset.LATEST
-) {
-  if (patchset === ChecksPatchset.LATEST) {
-    state.pluginStateLatest = {...state.pluginStateLatest};
-    return state.pluginStateLatest;
-  } else {
-    state.pluginStateSelected = {...state.pluginStateSelected};
-    return state.pluginStateSelected;
+  finalize() {
+    document.removeEventListener('reload', this.reloadListener);
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    this.privateState$.complete();
   }
-}
 
-export function updateStateSetLoading(
-  pluginName: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: true,
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetError(
-  pluginName: string,
-  errorMessage: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage,
-    loginCallback: undefined,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetNotLoggedIn(
-  pluginName: string,
-  loginCallback: () => void,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage: undefined,
-    loginCallback,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetResults(
-  pluginName: string,
-  runs: CheckRunApi[],
-  actions: Action[] = [],
-  links: Link[] = [],
-  patchset: ChecksPatchset
-) {
-  const attemptMap = createAttemptMap(runs);
-  for (const attemptInfo of attemptMap.values()) {
-    // Per run only one attempt can be undefined, so the '?? -1' is not really
-    // relevant for sorting.
-    attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
+  // Must only be used by the checks service or whatever is in control of this
+  // model.
+  updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      pluginName,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    };
+    this.privateState$.next(nextState);
   }
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage: undefined,
-    loginCallback: undefined,
-    runs: runs.map(run => {
-      const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
-      const attemptInfo = attemptMap.get(run.checkName);
-      assertIsDefined(attemptInfo, 'attemptInfo');
-      return {
-        ...run,
-        pluginName,
-        internalRunId: runId,
-        isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
-        isSingleAttempt: attemptInfo.isSingleAttempt,
-        attemptDetails: attemptInfo.attempts,
-        results: (run.results ?? []).map((result, i) => {
-          return {
-            ...result,
-            internalResultId: `${runId}-${i}`,
-          };
-        }),
-      };
-    }),
-    actions: [...actions],
-    links: [...links],
-  };
-  privateState$.next(nextState);
-}
 
-export function updateStateUpdateResult(
-  pluginName: string,
-  updatedRun: CheckRunApi,
-  updatedResult: CheckResultApi,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  let runUpdated = false;
-  const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
-    if (run.change !== updatedRun.change) return run;
-    if (run.patchset !== updatedRun.patchset) return run;
-    if (run.attempt !== updatedRun.attempt) return run;
-    if (run.checkName !== updatedRun.checkName) return run;
-    let resultUpdated = false;
-    const results: CheckResult[] = (run.results ?? []).map(result => {
-      if (result.externalId && result.externalId === updatedResult.externalId) {
-        runUpdated = true;
-        resultUpdated = true;
+  getPluginState(
+    state: ChecksState,
+    patchset: ChecksPatchset = ChecksPatchset.LATEST
+  ) {
+    if (patchset === ChecksPatchset.LATEST) {
+      state.pluginStateLatest = {...state.pluginStateLatest};
+      return state.pluginStateLatest;
+    } else {
+      state.pluginStateSelected = {...state.pluginStateSelected};
+      return state.pluginStateSelected;
+    }
+  }
+
+  updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: true,
+    };
+    this.privateState$.next(nextState);
+  }
+
+  updateStateSetError(
+    pluginName: string,
+    errorMessage: string,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage,
+      loginCallback: undefined,
+      runs: [],
+      actions: [],
+    };
+    this.privateState$.next(nextState);
+  }
+
+  updateStateSetNotLoggedIn(
+    pluginName: string,
+    loginCallback: () => void,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage: undefined,
+      loginCallback,
+      runs: [],
+      actions: [],
+    };
+    this.privateState$.next(nextState);
+  }
+
+  updateStateSetResults(
+    pluginName: string,
+    runs: CheckRunApi[],
+    actions: Action[] = [],
+    links: Link[] = [],
+    patchset: ChecksPatchset
+  ) {
+    const attemptMap = createAttemptMap(runs);
+    for (const attemptInfo of attemptMap.values()) {
+      // Per run only one attempt can be undefined, so the '?? -1' is not really
+      // relevant for sorting.
+      attemptInfo.attempts.sort(
+        (a, b) => (a.attempt ?? -1) - (b.attempt ?? -1)
+      );
+    }
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage: undefined,
+      loginCallback: undefined,
+      runs: runs.map(run => {
+        const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
+        const attemptInfo = attemptMap.get(run.checkName);
+        assertIsDefined(attemptInfo, 'attemptInfo');
         return {
-          ...updatedResult,
-          internalResultId: result.internalResultId,
+          ...run,
+          pluginName,
+          internalRunId: runId,
+          isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+          isSingleAttempt: attemptInfo.isSingleAttempt,
+          attemptDetails: attemptInfo.attempts,
+          results: (run.results ?? []).map((result, i) => {
+            return {
+              ...result,
+              internalResultId: `${runId}-${i}`,
+            };
+          }),
         };
-      }
-      return result;
-    });
-    return resultUpdated ? {...run, results} : run;
-  });
-  if (!runUpdated) return;
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    runs,
-  };
-  privateState$.next(nextState);
-}
+      }),
+      actions: [...actions],
+      links: [...links],
+    };
+    this.privateState$.next(nextState);
+  }
 
-export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
-  const nextState = {...privateState$.getValue()};
-  nextState.patchsetNumberSelected = patchsetNumber;
-  privateState$.next(nextState);
+  updateStateUpdateResult(
+    pluginName: string,
+    updatedRun: CheckRunApi,
+    updatedResult: CheckResultApi,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.privateState$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    let runUpdated = false;
+    const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+      if (run.change !== updatedRun.change) return run;
+      if (run.patchset !== updatedRun.patchset) return run;
+      if (run.attempt !== updatedRun.attempt) return run;
+      if (run.checkName !== updatedRun.checkName) return run;
+      let resultUpdated = false;
+      const results: CheckResult[] = (run.results ?? []).map(result => {
+        if (
+          result.externalId &&
+          result.externalId === updatedResult.externalId
+        ) {
+          runUpdated = true;
+          resultUpdated = true;
+          return {
+            ...updatedResult,
+            internalResultId: result.internalResultId,
+          };
+        }
+        return result;
+      });
+      return resultUpdated ? {...run, results} : run;
+    });
+    if (!runUpdated) return;
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      runs,
+    };
+    this.privateState$.next(nextState);
+  }
+
+  updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+    const nextState = {...this.privateState$.getValue()};
+    nextState.patchsetNumberSelected = patchsetNumber;
+    this.privateState$.next(nextState);
+  }
+
+  setPatchset(num?: PatchSetNumber) {
+    this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+  }
+
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    for (const key of Object.keys(this.providers)) {
+      this.reload(key);
+    }
+  }
+
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
+  updateResult(pluginName: string, run: CheckRunApi, result: CheckResultApi) {
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.LATEST
+    );
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.SELECTED
+    );
+  }
+
+  triggerAction(action?: Action, run?: CheckRun) {
+    if (!action?.callback) return;
+    if (!this.changeNum) return;
+    const patchSet = run?.patchset ?? this.latestPatchNum;
+    if (!patchSet) return;
+    const promise = action.callback(
+      this.changeNum,
+      patchSet,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
+      action.name
+    );
+    // If plugins return undefined or not a promise, then show no toast.
+    if (!promise?.then) return;
+
+    fireAlert(document, `Triggering action '${action.name}' ...`);
+    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 || result.message) {
+          fireAlert(document, `${result.message ?? result.errorMessage}`);
+        } else {
+          fireEvent(document, 'hide-alert');
+        }
+        if (result.shouldReload) {
+          this.reloadForCheck(run?.checkName);
+        }
+      });
+  }
+
+  register(
+    pluginName: string,
+    provider: ChecksProvider,
+    config: ChecksApiConfig
+  ) {
+    if (this.providers[pluginName]) {
+      console.warn(
+        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+      );
+      return;
+    }
+    this.providers[pluginName] = provider;
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.changeNum$,
+        patchset === ChecksPatchset.LATEST
+          ? this.changeModel.latestPatchNum$
+          : this.checksSelectedPatchsetNumber$,
+        this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+        timer(0, pollIntervalMs),
+        this.documentVisibilityChange$,
+      ])
+        .pipe(
+          takeWhile(_ => !!this.providers[pluginName]),
+          filter(_ => document.visibilityState !== 'hidden'),
+          withLatestFrom(this.changeModel.change$),
+          switchMap(
+            ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+              if (!change || !changeNum || !patchNum) return of(this.empty());
+              if (typeof patchNum !== 'number') return of(this.empty());
+              assertIsDefined(change.revisions, 'change.revisions');
+              const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+              // Sometimes patchNum is updated earlier than change, so change
+              // revisions don't have patchNum yet
+              if (!patchsetSha) return of(this.empty());
+              const data: ChangeData = {
+                changeNumber: changeNum,
+                patchsetNumber: patchNum,
+                patchsetSha,
+                repo: change.project,
+                commitMessage: getCurrentRevision(change)?.commit?.message,
+                changeInfo: change as ChangeInfo,
+              };
+              return this.fetchResults(pluginName, data, patchset);
+            }
+          ),
+          catchError(e => {
+            // This should not happen and is really severe, because it means that
+            // the Observable has terminated and we won't recover from that. No
+            // further attempts to fetch results for this plugin will be made.
+            this.reporting.error(e, `checks-model crash for ${pluginName}`);
+            return of(this.createErrorResponse(pluginName, e));
+          })
+        )
+        .subscribe(response => {
+          switch (response.responseCode) {
+            case ResponseCode.ERROR: {
+              const message = response.errorMessage ?? '-';
+              this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+                plugin: pluginName,
+                message,
+              });
+              this.updateStateSetError(pluginName, message, patchset);
+              break;
+            }
+            case ResponseCode.NOT_LOGGED_IN: {
+              assertIsDefined(response.loginCallback, 'loginCallback');
+              this.reporting.reportExecution(
+                Execution.CHECKS_API_NOT_LOGGED_IN,
+                {
+                  plugin: pluginName,
+                }
+              );
+              this.updateStateSetNotLoggedIn(
+                pluginName,
+                response.loginCallback,
+                patchset
+              );
+              break;
+            }
+            case ResponseCode.OK: {
+              this.updateStateSetResults(
+                pluginName,
+                response.runs ?? [],
+                response.actions ?? [],
+                response.links ?? [],
+                patchset
+              );
+              break;
+            }
+          }
+        })
+    );
+  }
+
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
+  private createErrorResponse(
+    pluginName: string,
+    message: object
+  ): FetchResponse {
+    return {
+      responseCode: ResponseCode.ERROR,
+      errorMessage:
+        `Error message from plugin '${pluginName}':` +
+        ` ${JSON.stringify(message)}`,
+    };
+  }
+
+  private fetchResults(
+    pluginName: string,
+    data: ChangeData,
+    patchset: ChecksPatchset
+  ): Observable<FetchResponse> {
+    this.updateStateSetLoading(pluginName, patchset);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+    return from(fetchPromise).pipe(
+      catchError(e => of(this.createErrorResponse(pluginName, e)))
+    );
+  }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 0be0451..bb90fe2 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -16,15 +16,9 @@
  */
 import '../../test/common-test-setup-karma';
 import './checks-model';
-import {
-  _testOnly_getState,
-  ChecksPatchset,
-  updateStateSetLoading,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
+import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
 import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {getAppContext} from '../app-context';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -45,14 +39,27 @@
   },
 ];
 
-function current() {
-  return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
-}
-
 suite('checks-model tests', () => {
-  test('updateStateSetProvider', () => {
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.deepEqual(current(), {
+  let model: ChecksModel;
+
+  let current: ChecksProviderState;
+
+  setup(() => {
+    model = new ChecksModel(
+      getAppContext().routerModel,
+      getAppContext().changeModel,
+      getAppContext().reportingService
+    );
+    model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('model.updateStateSetProvider', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.deepEqual(current, {
       pluginName: PLUGIN_NAME,
       loading: false,
       firstTimeLoad: true,
@@ -63,45 +70,69 @@
   });
 
   test('loading and first time load', () => {
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isFalse(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
   });
 
-  test('updateStateSetResults', () => {
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
+  test('model.updateStateSetResults', () => {
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
   });
 
-  test('updateStateUpdateResult', () => {
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+  test('model.updateStateUpdateResult', () => {
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      ChecksPatchset.LATEST
+    );
     assert.equal(
-      current().runs[0].results![0].summary,
+      current.runs[0].results![0].summary,
       RUNS[0]!.results![0].summary
     );
     const result = RUNS[0].results![0];
     const updatedResult = {...result, summary: 'new'};
-    updateStateUpdateResult(
+    model.updateStateUpdateResult(
       PLUGIN_NAME,
       RUNS[0],
       updatedResult,
       ChecksPatchset.LATEST
     );
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
-    assert.equal(current().runs[0].results![0].summary, 'new');
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
+    assert.equal(current.runs[0].results![0].summary, 'new');
   });
 });
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
deleted file mode 100644
index 111036c..0000000
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {
-  BehaviorSubject,
-  combineLatest,
-  from,
-  Observable,
-  of,
-  Subject,
-  Subscription,
-  timer,
-} from 'rxjs';
-import {
-  catchError,
-  filter,
-  switchMap,
-  takeUntil,
-  takeWhile,
-  throttleTime,
-  withLatestFrom,
-} from 'rxjs/operators';
-import {
-  Action,
-  ChangeData,
-  CheckResult,
-  CheckRun,
-  ChecksApiConfig,
-  ChecksProvider,
-  FetchResponse,
-  ResponseCode,
-} from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
-import {
-  ChecksPatchset,
-  checksSelectedPatchsetNumber$,
-  checkToPluginMap$,
-  updateStateSetError,
-  updateStateSetLoading,
-  updateStateSetNotLoggedIn,
-  updateStateSetPatchset,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
-import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
-import {Finalizable} from '../registry';
-import {getCurrentRevision} from '../../utils/change-util';
-import {getShaByPatchNum} from '../../utils/patch-set-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
-import {Execution} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-
-export class ChecksService implements Finalizable {
-  private readonly providers: {[name: string]: ChecksProvider} = {};
-
-  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
-
-  private checkToPluginMap = new Map<string, string>();
-
-  private changeNum?: NumericChangeId;
-
-  private latestPatchNum?: PatchSetNumber;
-
-  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
-
-  private readonly reloadListener: () => void;
-
-  private readonly subscriptions: Subscription[] = [];
-
-  private readonly visibilityChangeListener: () => void;
-
-  constructor(readonly reporting: ReportingService) {
-    this.subscriptions.push(changeNum$.subscribe(x => (this.changeNum = x)));
-    this.subscriptions.push(
-      checkToPluginMap$.subscribe(map => {
-        this.checkToPluginMap = map;
-      })
-    );
-    this.subscriptions.push(
-      combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
-        ([routerPs, latestPs]) => {
-          this.latestPatchNum = latestPs;
-          if (latestPs === undefined) {
-            this.setPatchset(undefined);
-          } else if (typeof routerPs === 'number') {
-            this.setPatchset(routerPs);
-          } else {
-            this.setPatchset(latestPs);
-          }
-        }
-      )
-    );
-    this.visibilityChangeListener = () => {
-      this.documentVisibilityChange$.next(undefined);
-    };
-    document.addEventListener(
-      'visibilitychange',
-      this.visibilityChangeListener
-    );
-    this.reloadListener = () => this.reloadAll();
-    document.addEventListener('reload', this.reloadListener);
-  }
-
-  finalize() {
-    document.removeEventListener('reload', this.reloadListener);
-    document.removeEventListener(
-      'visibilitychange',
-      this.visibilityChangeListener
-    );
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  setPatchset(num?: PatchSetNumber) {
-    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
-  }
-
-  reload(pluginName: string) {
-    this.reloadSubjects[pluginName].next();
-  }
-
-  reloadAll() {
-    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);
-  }
-
-  updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
-  }
-
-  triggerAction(action?: Action, run?: CheckRun) {
-    if (!action?.callback) return;
-    if (!this.changeNum) return;
-    const patchSet = run?.patchset ?? this.latestPatchNum;
-    if (!patchSet) return;
-    const promise = action.callback(
-      this.changeNum,
-      patchSet,
-      run?.attempt,
-      run?.externalId,
-      run?.checkName,
-      action.name
-    );
-    // If plugins return undefined or not a promise, then show no toast.
-    if (!promise?.then) return;
-
-    fireAlert(document, `Triggering action '${action.name}' ...`);
-    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 || result.message) {
-          fireAlert(document, `${result.message ?? result.errorMessage}`);
-        } else {
-          fireEvent(document, 'hide-alert');
-        }
-        if (result.shouldReload) {
-          this.reloadForCheck(run?.checkName);
-        }
-      });
-  }
-
-  register(
-    pluginName: string,
-    provider: ChecksProvider,
-    config: ChecksApiConfig
-  ) {
-    if (this.providers[pluginName]) {
-      console.warn(
-        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
-      );
-      return;
-    }
-    this.providers[pluginName] = provider;
-    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
-    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
-  }
-
-  initFetchingOfData(
-    pluginName: string,
-    config: ChecksApiConfig,
-    patchset: ChecksPatchset
-  ) {
-    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
-    // Various events should trigger fetching checks from the provider:
-    // 1. Change number and patchset number changes.
-    // 2. Specific reload requests.
-    // 3. Regular polling starting with an initial fetch right now.
-    // 4. A hidden Gerrit tab becoming visible.
-    this.subscriptions.push(
-      combineLatest([
-        changeNum$,
-        patchset === ChecksPatchset.LATEST
-          ? latestPatchNum$
-          : checksSelectedPatchsetNumber$,
-        this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
-        timer(0, pollIntervalMs),
-        this.documentVisibilityChange$,
-      ])
-        .pipe(
-          takeWhile(_ => !!this.providers[pluginName]),
-          filter(_ => document.visibilityState !== 'hidden'),
-          withLatestFrom(change$),
-          switchMap(
-            ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
-              if (!change || !changeNum || !patchNum) return of(this.empty());
-              if (typeof patchNum !== 'number') return of(this.empty());
-              assertIsDefined(change.revisions, 'change.revisions');
-              const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
-              // Sometimes patchNum is updated earlier than change, so change
-              // revisions don't have patchNum yet
-              if (!patchsetSha) return of(this.empty());
-              const data: ChangeData = {
-                changeNumber: changeNum,
-                patchsetNumber: patchNum,
-                patchsetSha,
-                repo: change.project,
-                commitMessage: getCurrentRevision(change)?.commit?.message,
-                changeInfo: change as ChangeInfo,
-              };
-              return this.fetchResults(pluginName, data, patchset);
-            }
-          ),
-          catchError(e => {
-            // This should not happen and is really severe, because it means that
-            // the Observable has terminated and we won't recover from that. No
-            // further attempts to fetch results for this plugin will be made.
-            this.reporting.error(e, `checks-service crash for ${pluginName}`);
-            return of(this.createErrorResponse(pluginName, e));
-          })
-        )
-        .subscribe(response => {
-          switch (response.responseCode) {
-            case ResponseCode.ERROR: {
-              const message = response.errorMessage ?? '-';
-              this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
-                plugin: pluginName,
-                message,
-              });
-              updateStateSetError(pluginName, message, patchset);
-              break;
-            }
-            case ResponseCode.NOT_LOGGED_IN: {
-              assertIsDefined(response.loginCallback, 'loginCallback');
-              this.reporting.reportExecution(
-                Execution.CHECKS_API_NOT_LOGGED_IN,
-                {
-                  plugin: pluginName,
-                }
-              );
-              updateStateSetNotLoggedIn(
-                pluginName,
-                response.loginCallback,
-                patchset
-              );
-              break;
-            }
-            case ResponseCode.OK: {
-              updateStateSetResults(
-                pluginName,
-                response.runs ?? [],
-                response.actions ?? [],
-                response.links ?? [],
-                patchset
-              );
-              break;
-            }
-          }
-        })
-    );
-  }
-
-  private empty(): FetchResponse {
-    return {
-      responseCode: ResponseCode.OK,
-      runs: [],
-    };
-  }
-
-  private createErrorResponse(
-    pluginName: string,
-    message: object
-  ): FetchResponse {
-    return {
-      responseCode: ResponseCode.ERROR,
-      errorMessage:
-        `Error message from plugin '${pluginName}':` +
-        ` ${JSON.stringify(message)}`,
-    };
-  }
-
-  private fetchResults(
-    pluginName: string,
-    data: ChangeData,
-    patchset: ChecksPatchset
-  ): Observable<FetchResponse> {
-    updateStateSetLoading(pluginName, patchset);
-    const timer = this.reporting.getTimer('ChecksPluginFetch');
-    const fetchPromise = this.providers[pluginName]
-      .fetch(data)
-      .then(response => {
-        timer.end({pluginName});
-        return response;
-      });
-    return from(fetchPromise).pipe(
-      catchError(e => of(this.createErrorResponse(pluginName, e)))
-    );
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index ad7865b39..95a1030 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -15,23 +15,53 @@
  * limitations under the License.
  */
 
-import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
+import {BehaviorSubject} from 'rxjs';
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
+  CommentBasics,
   CommentInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionId,
+  UrlEncodedCommentId,
   PathToCommentsInfoMap,
   RobotCommentInfo,
 } from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
+import {
+  addPath,
+  DraftInfo,
+  isDraft,
+  isUnsaved,
+  reportingDetails,
+  UnsavedInfo,
+} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
+import {RouterModel} from '../router/router-model';
+import {Finalizable} from '../registry';
+import {combineLatest, Subscription} from 'rxjs';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {Interaction} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
 
-interface CommentState {
+export interface CommentState {
   /** undefined means 'still loading' */
   comments?: PathToCommentsInfoMap;
   /** undefined means 'still loading' */
   robotComments?: {[path: string]: RobotCommentInfo[]};
+  // All drafts are DraftInfo objects and have __draft = true set.
+  // Drafts have an id and are known to the backend. Unsaved drafts
+  // (see UnsavedInfo) do NOT belong in the application model.
   /** undefined means 'still loading' */
   drafts?: {[path: string]: DraftInfo[]};
+  // Ported comments only affect `CommentThread` properties, not individual
+  // comments.
   /** undefined means 'still loading' */
   portedComments?: PathToCommentsInfoMap;
   /** undefined means 'still loading' */
@@ -53,60 +83,179 @@
   discardedDrafts: [],
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+const TOAST_DEBOUNCE_INTERVAL = 200;
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+  if (requestFailed) {
+    return 'Unable to save draft';
+  }
+  if (numPending === 0) {
+    return 'All changes saved';
+  }
+  return `Saving ${pluralize(numPending, 'draft')}...`;
 }
 
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
+// Private but used in tests.
+export function setComments(
+  state: CommentState,
+  comments?: {
+    [path: string]: CommentInfo[];
+  }
+): CommentState {
+  const nextState = {...state};
+  if (deepEqual(comments, nextState.comments)) return state;
+  nextState.comments = addPath(comments) || {};
+  return nextState;
 }
 
-export function _testOnly_setState(state: CommentState) {
-  privateState$.next(state);
+// Private but used in tests.
+export function setRobotComments(
+  state: CommentState,
+  robotComments?: {
+    [path: string]: RobotCommentInfo[];
+  }
+): CommentState {
+  if (deepEqual(robotComments, state.robotComments)) return state;
+  const nextState = {...state};
+  nextState.robotComments = addPath(robotComments) || {};
+  return nextState;
 }
 
-export const commentsLoading$ = commentState$.pipe(
-  map(
+// Private but used in tests.
+export function setDrafts(
+  state: CommentState,
+  drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+  if (deepEqual(drafts, state.drafts)) return state;
+  const nextState = {...state};
+  nextState.drafts = addPath(drafts);
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedComments(
+  state: CommentState,
+  portedComments?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedComments, state.portedComments)) return state;
+  const nextState = {...state};
+  nextState.portedComments = portedComments || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedDrafts(
+  state: CommentState,
+  portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+  const nextState = {...state};
+  nextState.portedDrafts = portedDrafts || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setDiscardedDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+  return nextState;
+}
+
+// Private but used in tests.
+export function deleteDiscardedDraft(
+  state: CommentState,
+  draftID?: string
+): CommentState {
+  const nextState = {...state};
+  const drafts = [...nextState.discardedDrafts];
+  const index = drafts.findIndex(d => d.id === draftID);
+  if (index === -1) {
+    throw new Error('discarded draft not found');
+  }
+  drafts.splice(index, 1);
+  nextState.discardedDrafts = drafts;
+  return nextState;
+}
+
+/** Adds or updates a draft. */
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  return nextState;
+}
+
+export function deleteDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d => d.id && d.id === draft.id
+  );
+  if (index === -1) return state;
+  const discardedDraft = drafts[draft.path][index];
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export class CommentsModel implements Finalizable {
+  private readonly privateState$: BehaviorSubject<CommentState> =
+    new BehaviorSubject(initialState);
+
+  public readonly commentsLoading$ = select(
+    this.privateState$,
     commentState =>
       commentState.comments === undefined ||
       commentState.robotComments === undefined ||
       commentState.drafts === undefined
-  ),
-  distinctUntilChanged()
-);
+  );
 
-export const comments$ = commentState$.pipe(
-  map(commentState => commentState.comments),
-  distinctUntilChanged()
-);
+  public readonly comments$ = select(
+    this.privateState$,
+    commentState => commentState.comments
+  );
 
-export const drafts$ = commentState$.pipe(
-  map(commentState => commentState.drafts),
-  distinctUntilChanged()
-);
+  public readonly drafts$ = select(
+    this.privateState$,
+    commentState => commentState.drafts
+  );
 
-export const portedComments$ = commentState$.pipe(
-  map(commentState => commentState.portedComments),
-  distinctUntilChanged()
-);
+  public readonly portedComments$ = select(
+    this.privateState$,
+    commentState => commentState.portedComments
+  );
 
-export const discardedDrafts$ = commentState$.pipe(
-  map(commentState => commentState.discardedDrafts),
-  distinctUntilChanged()
-);
+  public readonly discardedDrafts$ = select(
+    this.privateState$,
+    commentState => commentState.discardedDrafts
+  );
 
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
-  map(
+  // Emits a new value even if only a single draft is changed. Components should
+  // aim to subsribe to something more specific.
+  public readonly changeComments$ = select(
+    this.privateState$,
     commentState =>
       new ChangeComments(
         commentState.comments,
@@ -115,128 +264,283 @@
         commentState.portedComments,
         commentState.portedDrafts
       )
-  )
-);
+  );
 
-export const threads$ = changeComments$.pipe(
-  map(changeComments => changeComments.getAllThreadsForChange())
-);
+  public readonly threads$ = select(this.changeComments$, changeComments =>
+    changeComments.getAllThreadsForChange()
+  );
 
-function publishState(state: CommentState) {
-  privateState$.next(state);
-}
-
-/** Called when the change number changes. Wipes out all data from the state. */
-export function updateStateReset() {
-  publishState({...initialState});
-}
-
-export function updateStateComments(comments?: {
-  [path: string]: CommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.comments = addPath(comments) || {};
-  publishState(nextState);
-}
-
-export function updateStateRobotComments(robotComments?: {
-  [path: string]: RobotCommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.robotComments = addPath(robotComments) || {};
-  publishState(nextState);
-}
-
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.drafts = addPath(drafts) || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedComments(
-  portedComments?: PathToCommentsInfoMap
-) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedComments = portedComments || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedDrafts = portedDrafts || {};
-  publishState(nextState);
-}
-
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
-  publishState(nextState);
-}
-
-export function updateStateUndoDiscardedDraft(draftID?: string) {
-  const nextState = {...privateState$.getValue()};
-  const drafts = [...nextState.discardedDrafts];
-  const index = drafts.findIndex(d => d.id === draftID);
-  if (index === -1) {
-    throw new Error('discarded draft not found');
+  public thread$(id: UrlEncodedCommentId) {
+    return select(this.threads$, threads => threads.find(t => t.rootId === id));
   }
-  drafts.splice(index, 1);
-  nextState.discardedDrafts = drafts;
-  publishState(nextState);
-}
 
-export function updateStateAddDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
-  else drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index !== -1) {
-    drafts[draft.path][index] = draft;
-  } else {
-    drafts[draft.path].push(draft);
+  private numPendingDraftRequests = 0;
+
+  private changeNum?: NumericChangeId;
+
+  private patchNum?: PatchSetNum;
+
+  private readonly reloadListener: () => void;
+
+  private readonly subscriptions: Subscription[] = [];
+
+  private drafts: {[path: string]: DraftInfo[]} = {};
+
+  private draftToastTask?: DelayedTask;
+
+  private discardedDrafts: DraftInfo[] = [];
+
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService,
+    readonly reporting: ReportingService
+  ) {
+    this.subscriptions.push(
+      this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+    );
+    this.subscriptions.push(
+      this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+    );
+    this.subscriptions.push(
+      this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+    );
+    this.subscriptions.push(
+      this.routerModel.routerChangeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+        this.setState({...initialState});
+        this.reloadAllComments();
+      })
+    );
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.changeNum$,
+        this.changeModel.currentPatchNum$,
+      ]).subscribe(([changeNum, patchNum]) => {
+        this.changeNum = changeNum;
+        this.patchNum = patchNum;
+        this.reloadAllPortedComments();
+      })
+    );
+    this.reloadListener = () => {
+      this.reloadAllComments();
+      this.reloadAllPortedComments();
+    };
+    document.addEventListener('reload', this.reloadListener);
   }
-  publishState(nextState);
-}
 
-export function updateStateUpdateDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path])
-    throw new Error('draft: trying to edit non-existent draft');
-  drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  drafts[draft.path][index] = draft;
-  publishState(nextState);
-}
+  finalize() {
+    document.removeEventListener('reload', this.reloadListener!);
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions.splice(0, this.subscriptions.length);
+  }
 
-export function updateStateDeleteDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  const index = (drafts[draft.path] || []).findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  const discardedDraft = drafts[draft.path][index];
-  drafts[draft.path] = [...drafts[draft.path]];
-  drafts[draft.path].splice(index, 1);
-  publishState(nextState);
-  updateStateAddDiscardedDraft(discardedDraft);
+  // Note that this does *not* reload ported comments.
+  async reloadAllComments() {
+    if (!this.changeNum) return;
+    await Promise.all([
+      this.reloadComments(this.changeNum),
+      this.reloadRobotComments(this.changeNum),
+      this.reloadDrafts(this.changeNum),
+    ]);
+  }
+
+  async reloadAllPortedComments() {
+    if (!this.changeNum) return;
+    if (!this.patchNum) return;
+    await Promise.all([
+      this.reloadPortedComments(this.changeNum, this.patchNum),
+      this.reloadPortedDrafts(this.changeNum, this.patchNum),
+    ]);
+  }
+
+  // visible for testing
+  updateState(reducer: (state: CommentState) => CommentState) {
+    const current = this.privateState$.getValue();
+    this.setState(reducer({...current}));
+  }
+
+  // visible for testing
+  setState(state: CommentState) {
+    this.privateState$.next(state);
+  }
+
+  async reloadComments(changeNum: NumericChangeId): Promise<void> {
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    this.updateState(s => setComments(s, comments));
+  }
+
+  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+    const robotComments = await this.restApiService.getDiffRobotComments(
+      changeNum
+    );
+    this.updateState(s => setRobotComments(s, robotComments));
+  }
+
+  async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+    const drafts = await this.restApiService.getDiffDrafts(changeNum);
+    this.updateState(s => setDrafts(s, drafts));
+  }
+
+  async reloadPortedComments(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedComments = await this.restApiService.getPortedComments(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedComments(s, portedComments));
+  }
+
+  async reloadPortedDrafts(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedDrafts = await this.restApiService.getPortedDrafts(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedDrafts(s, portedDrafts));
+  }
+
+  async restoreDraft(id: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => d.id === id);
+    if (!found) throw new Error('discarded draft not found');
+    const newDraft = {
+      ...found,
+      id: undefined,
+      updated: undefined,
+      __draft: undefined,
+      __unsaved: true,
+    };
+    await this.saveDraft(newDraft);
+    this.updateState(s => deleteDiscardedDraft(s, id));
+  }
+
+  /**
+   * Saves a new or updates an existing draft.
+   * The model will only be updated when a successful response comes back.
+   */
+  async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.SAVE_COMMENT, draft);
+    if (showToast) this.showStartRequest();
+    const result = await this.restApiService.saveDiffDraft(
+      changeNum,
+      draft.patch_set,
+      draft
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      if (showToast) this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to save draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    const obj = await this.restApiService.getResponseObject(result);
+    const savedComment = obj as unknown as CommentInfo;
+    const updatedDraft = {
+      ...draft,
+      id: savedComment.id,
+      updated: savedComment.updated,
+      __draft: true,
+      __unsaved: undefined,
+    };
+    if (showToast) this.showEndRequest();
+    this.updateState(s => setDraft(s, updatedDraft));
+    this.report(Interaction.COMMENT_SAVED, updatedDraft);
+  }
+
+  async discardDraft(draftId: UrlEncodedCommentId) {
+    const draft = this.lookupDraft(draftId);
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft, `draft not found by id ${draftId}`);
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.DISCARD_COMMENT, draft);
+    this.showStartRequest();
+    const result = await this.restApiService.deleteDiffDraft(
+      changeNum,
+      draft.patch_set,
+      {id: draft.id}
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to discard draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    this.showEndRequest();
+    this.updateState(s => deleteDraft(s, draft));
+    // We don't store empty discarded drafts and don't need an UNDO then.
+    if (draft.message?.trim()) {
+      fire(document, 'show-alert', {
+        message: 'Draft Discarded',
+        action: 'Undo',
+        callback: () => this.restoreDraft(draft.id),
+      });
+    }
+    this.report(Interaction.COMMENT_DISCARDED, draft);
+  }
+
+  private report(interaction: Interaction, comment: CommentBasics) {
+    const details = reportingDetails(comment);
+    this.reporting.reportInteraction(interaction, details);
+  }
+
+  private showStartRequest() {
+    this.numPendingDraftRequests += 1;
+    this.updateRequestToast();
+  }
+
+  private showEndRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast();
+  }
+
+  private handleFailedDraftRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast(/* requestFailed=*/ true);
+  }
+
+  private updateRequestToast(requestFailed?: boolean) {
+    if (this.numPendingDraftRequests === 0 && !requestFailed) {
+      fireEvent(document, 'hide-alert');
+      return;
+    }
+    const message = getSavingMessage(
+      this.numPendingDraftRequests,
+      requestFailed
+    );
+    this.draftToastTask = debounce(
+      this.draftToastTask,
+      () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        fireAlert(document.body, message);
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+    return Object.values(this.drafts)
+      .flat()
+      .find(d => d.id === id);
+  }
 }
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index 30fc7cf..a8f2118 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -19,17 +19,25 @@
 import {UrlEncodedCommentId} from '../../types/common';
 import {DraftInfo} from '../../utils/comment-util';
 import './comments-model';
+import {CommentsModel} from './comments-model';
+import {deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import '../../test/common-test-setup-karma';
 import {
-  updateStateDeleteDraft,
-  _testOnly_getState,
-  _testOnly_setState,
-} from './comments-model';
+  createComment,
+  createParsedChange,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {getAppContext} from '../app-context';
+import {GerritView} from '../router/router-model';
+import {PathToCommentsInfoMap} from '../../types/common';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
     const draft = createDraft();
     draft.id = '1' as UrlEncodedCommentId;
-    _testOnly_setState({
+    const state = {
       comments: {},
       robotComments: {},
       drafts: {
@@ -38,9 +46,9 @@
       portedComments: {},
       portedDrafts: {},
       discardedDrafts: [],
-    });
-    updateStateDeleteDraft(draft);
-    assert.deepEqual(_testOnly_getState(), {
+    };
+    const output = deleteDraft(state, draft);
+    assert.deepEqual(output, {
       comments: {},
       robotComments: {},
       drafts: {
@@ -52,3 +60,69 @@
     });
   });
 });
+
+suite('change service tests', () => {
+  let subscriptions: Subscription[] = [];
+
+  teardown(() => {
+    for (const s of subscriptions) {
+      s.unsubscribe();
+    }
+    subscriptions = [];
+  });
+
+  test('loads comments', async () => {
+    const model = new CommentsModel(
+      getAppContext().routerModel,
+      getAppContext().changeModel,
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({})
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+      Promise.resolve({})
+    );
+    let comments: PathToCommentsInfoMap = {};
+    subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+    let portedComments: PathToCommentsInfoMap = {};
+    subscriptions.push(
+      model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+    );
+
+    model.routerModel.updateState({
+      view: GerritView.CHANGE,
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+    });
+    model.changeModel.updateStateChange(createParsedChange());
+
+    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+    await waitUntil(
+      () => Object.keys(comments).length > 0,
+      'comment in model not set'
+    );
+    await waitUntil(
+      () => Object.keys(portedComments).length > 0,
+      'ported comment in model not set'
+    );
+
+    assert.equal(comments['foo.c'].length, 1);
+    assert.equal(comments['foo.c'][0].id, '12345');
+    assert.equal(portedComments['foo.c'].length, 1);
+    assert.equal(portedComments['foo.c'][0].id, '12345');
+  });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index c888cd5..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {combineLatest, Subscription} from 'rxjs';
-import {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
-import {CURRENT} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {
-  updateStateAddDraft,
-  updateStateDeleteDraft,
-  updateStateUpdateDraft,
-  updateStateComments,
-  updateStateRobotComments,
-  updateStateDrafts,
-  updateStatePortedComments,
-  updateStatePortedDrafts,
-  updateStateUndoDiscardedDraft,
-  discardedDrafts$,
-  updateStateReset,
-} from './comments-model';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
-
-import {routerChangeNum$} from '../router/router-model';
-import {Finalizable} from '../registry';
-
-export class CommentsService implements Finalizable {
-  private discardedDrafts?: UIDraft[] = [];
-
-  private changeNum?: NumericChangeId;
-
-  private patchNum?: PatchSetNum;
-
-  private readonly reloadListener: () => void;
-
-  private readonly subscriptions: Subscription[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    this.subscriptions.push(
-      discardedDrafts$.subscribe(
-        discardedDrafts => (this.discardedDrafts = discardedDrafts)
-      )
-    );
-    this.subscriptions.push(
-      routerChangeNum$.subscribe(changeNum => {
-        this.changeNum = changeNum;
-        updateStateReset();
-        this.reloadAllComments();
-      })
-    );
-    this.subscriptions.push(
-      combineLatest([changeNum$, currentPatchNum$]).subscribe(
-        ([changeNum, patchNum]) => {
-          this.changeNum = changeNum;
-          this.patchNum = patchNum;
-          this.reloadAllPortedComments();
-        }
-      )
-    );
-    this.reloadListener = () => {
-      this.reloadAllComments();
-      this.reloadAllPortedComments();
-    };
-    document.addEventListener('reload', this.reloadListener);
-  }
-
-  finalize() {
-    document.removeEventListener('reload', this.reloadListener!);
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  // Note that this does *not* reload ported comments.
-  reloadAllComments() {
-    if (!this.changeNum) return;
-    this.reloadComments(this.changeNum);
-    this.reloadRobotComments(this.changeNum);
-    this.reloadDrafts(this.changeNum);
-  }
-
-  reloadAllPortedComments() {
-    if (!this.changeNum) return;
-    if (!this.patchNum) return;
-    this.reloadPortedComments(this.changeNum, this.patchNum);
-    this.reloadPortedDrafts(this.changeNum, this.patchNum);
-  }
-
-  reloadComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffComments(changeNum)
-      .then(comments => updateStateComments(comments));
-  }
-
-  reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffRobotComments(changeNum)
-      .then(robotComments => updateStateRobotComments(robotComments));
-  }
-
-  reloadDrafts(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffDrafts(changeNum)
-      .then(drafts => updateStateDrafts(drafts));
-  }
-
-  reloadPortedComments(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedComments(changeNum, patchNum)
-      .then(portedComments => updateStatePortedComments(portedComments));
-  }
-
-  reloadPortedDrafts(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedDrafts(changeNum, patchNum)
-      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
-  }
-
-  restoreDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draftID: string
-  ) {
-    const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
-    if (!draft) throw new Error('discarded draft not found');
-    // delete draft ID since we want to treat this as a new draft creation
-    delete draft.id;
-    this.restApiService
-      .saveDiffDraft(changeNum, patchNum, draft)
-      .then(result => {
-        if (!result.ok) {
-          fireAlert(document, 'Unable to restore draft');
-          return;
-        }
-        this.restApiService.getResponseObject(result).then(obj => {
-          const resComment = obj as unknown as DraftInfo;
-          resComment.patch_set = draft.patch_set;
-          updateStateAddDraft(resComment);
-          updateStateUndoDiscardedDraft(draftID);
-        });
-      });
-  }
-
-  addDraft(draft: DraftInfo) {
-    updateStateAddDraft(draft);
-  }
-
-  cancelDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  editDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  deleteDraft(draft: DraftInfo) {
-    updateStateDeleteDraft(draft);
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index 5fe859f..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {Subscription} from 'rxjs';
-import '../../test/common-test-setup-karma';
-import {
-  createComment,
-  createParsedChange,
-  TEST_NUMERIC_CHANGE_ID,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
-import {getAppContext} from '../app-context';
-import {CommentsService} from './comments-service';
-import {updateStateChange} from '../change/change-model';
-import {
-  GerritView,
-  updateState as updateRouterState,
-} from '../router/router-model';
-import {comments$, portedComments$} from './comments-model';
-import {PathToCommentsInfoMap} from '../../types/common';
-
-suite('change service tests', () => {
-  let subscriptions: Subscription[] = [];
-
-  teardown(() => {
-    for (const s of subscriptions) {
-      s.unsubscribe();
-    }
-    subscriptions = [];
-  });
-
-  test('loads comments', async () => {
-    new CommentsService(getAppContext().restApiService);
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({})
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-      Promise.resolve({})
-    );
-    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
-      Promise.resolve({})
-    );
-    let comments: PathToCommentsInfoMap = {};
-    subscriptions.push(comments$.subscribe(c => (comments = c ?? {})));
-    let portedComments: PathToCommentsInfoMap = {};
-    subscriptions.push(
-      portedComments$.subscribe(c => (portedComments = c ?? {}))
-    );
-
-    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
-    updateStateChange(createParsedChange());
-
-    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
-    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
-    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
-    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
-    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
-    await waitUntil(
-      () => Object.keys(comments).length > 0,
-      'comment in model not set'
-    );
-    await waitUntil(
-      () => Object.keys(portedComments).length > 0,
-      'ported comment in model not set'
-    );
-
-    assert.equal(comments['foo.c'].length, 1);
-    assert.equal(comments['foo.c'][0].id, '12345');
-    assert.equal(portedComments['foo.c'].length, 1);
-    assert.equal(portedComments['foo.c'][0].id, '12345');
-  });
-});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index f5e10c5..91a77b8 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -14,40 +14,82 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ConfigInfo, ServerInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
+import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
+import {BehaviorSubject, from, Observable, of, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {select} from '../../utils/observable-util';
 
-interface ConfigState {
+export interface ConfigState {
   repoConfig?: ConfigInfo;
   serverConfig?: ServerInfo;
 }
 
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: ConfigState = {};
+export class ConfigModel implements Finalizable {
+  // TODO: Figure out how to best enforce immutability of all states. Use Immer?
+  // Use DeepReadOnly?
+  private initialState: ConfigState = {};
 
-const privateState$ = new BehaviorSubject(initialState);
+  private privateState$ = new BehaviorSubject(this.initialState);
 
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const configState$: Observable<ConfigState> = privateState$;
+  // Re-exporting as Observable so that you can only subscribe, but not emit.
+  public configState$: Observable<ConfigState> =
+    this.privateState$.asObservable();
 
-export function updateRepoConfig(repoConfig?: ConfigInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, repoConfig});
+  public repoConfig$ = select(
+    this.privateState$,
+    configState => configState.repoConfig
+  );
+
+  public repoCommentLinks$ = select(
+    this.repoConfig$,
+    repoConfig => repoConfig?.commentlinks ?? {}
+  );
+
+  public serverConfig$ = select(
+    this.privateState$,
+    configState => configState.serverConfig
+  );
+
+  private subscriptions: Subscription[];
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService
+  ) {
+    this.subscriptions = [
+      from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+        this.updateServerConfig(config);
+      }),
+      this.changeModel.repo$
+        .pipe(
+          switchMap((repo?: RepoName) => {
+            if (repo === undefined) return of(undefined);
+            return from(this.restApiService.getProjectConfig(repo));
+          })
+        )
+        .subscribe((repoConfig?: ConfigInfo) => {
+          this.updateRepoConfig(repoConfig);
+        }),
+    ];
+  }
+
+  updateRepoConfig(repoConfig?: ConfigInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, repoConfig});
+  }
+
+  updateServerConfig(serverConfig?: ServerInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, serverConfig});
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
 }
-
-export function updateServerConfig(serverConfig?: ServerInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, serverConfig});
-}
-
-export const repoConfig$ = configState$.pipe(
-  map(configState => configState.repoConfig),
-  distinctUntilChanged()
-);
-
-export const serverConfig$ = configState$.pipe(
-  map(configState => configState.serverConfig),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
deleted file mode 100644
index 667f347..0000000
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {updateRepoConfig, updateServerConfig} from './config-model';
-import {repo$} from '../change/change-model';
-import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of, Subscription} from 'rxjs';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ConfigService implements Finalizable {
-  private readonly subscriptions: Subscription[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    this.subscriptions.push(
-      from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
-        updateServerConfig(config);
-      })
-    );
-    this.subscriptions.push(
-      repo$
-        .pipe(
-          switchMap((repo?: RepoName) => {
-            if (repo === undefined) return of(undefined);
-            return from(this.restApiService.getProjectConfig(repo));
-          })
-        )
-        .subscribe((repoConfig?: ConfigInfo) => {
-          updateRepoConfig(repoConfig);
-        })
-    );
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b688e8f..a4ab95c 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -30,4 +30,5 @@
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   TOPICS_PAGE = 'UiFeature__topics_page',
+  CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 679fefc..518716b 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -115,11 +115,6 @@
     eventName: string | Interaction,
     details?: EventDetails
   ): void;
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * timer.
-   */
-  recordDraftInteraction(): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
   setChangeId(changeId: NumericChangeId): 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 7d03de5..a01e9db 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -98,8 +98,6 @@
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 const SLOW_RPC_THRESHOLD = 500;
 
 export function initErrorReporter(reportingService: ReportingService) {
@@ -282,10 +280,6 @@
 
   private reportChangeId: NumericChangeId | undefined;
 
-  private timers: {timeBetweenDraftActions: Timer | null} = {
-    timeBetweenDraftActions: null,
-  };
-
   private pending: PendingReportInfo[] = [];
 
   private slowRpcList: SlowRpcCall[] = [];
@@ -855,27 +849,6 @@
     this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
   }
 
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * Timing.
-   */
-  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;
-    if (!timer) {
-      // Create a timer with a maximum length.
-      this.timers.timeBetweenDraftActions = this.getTimer(
-        DRAFT_ACTION_TIMER
-      ).withMaximum(DRAFT_ACTION_TIMER_MAX);
-      return;
-    }
-
-    // Mark the time and reinitialize the timer.
-    timer.end().reset();
-  }
-
   error(error: Error, errorSource?: string, details?: EventDetails) {
     const eventDetails = details ?? {};
     const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 485402b..2a5c532 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -59,7 +59,6 @@
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
-  recordDraftInteraction: () => {},
   reporter: () => {},
   reportErrorDialog: (message: string) => {
     log(`reportErrorDialog: ${message}`);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index f6e87f9..8068dc00 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -282,30 +282,6 @@
     assert.isTrue(service.reporter.calledOnce);
   });
 
-  test('recordDraftInteraction', () => {
-    const key = 'TimeBetweenDraftActions';
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timingStub = sinon.stub(service, '_reportTiming');
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.called);
-
-    nowStub.returns(200);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledOnce);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 100);
-
-    nowStub.returns(350);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledTwice);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 150);
-
-    nowStub.returns(370 + 2 * 60 * 1000);
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.calledThrice);
-  });
-
   test('timeEndWithAverage', () => {
     const nowStub = sinon.stub(window.performance, 'now').returns(0);
     nowStub.returns(1000);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 837cab6..40ef0ed 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -109,6 +109,7 @@
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
 
 export type CancelConditionCallback = () => boolean;
 
@@ -402,10 +403,6 @@
     draft: CommentInput
   ): Promise<Response>;
 
-  getDiffChangeDetail(
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo | undefined | null>;
-
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
@@ -454,21 +451,7 @@
 
   getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ):
-    | Promise<GetDiffCommentsOutput>
-    | Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
 
@@ -648,7 +631,8 @@
   ): Promise<RelatedChangesInfo | undefined>;
 
   getChangesSubmittedTogether(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    options?: string[]
   ): Promise<SubmittedTogetherInfo | undefined>;
 
   getChangeConflicts(
@@ -663,7 +647,10 @@
 
   getChangesWithSameTopic(
     topic: string,
-    changeNum: NumericChangeId
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
 
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index ab204a2..e7de1ef 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -40,7 +40,9 @@
       for (const key of Object.getOwnPropertyNames(registry)) {
         const name = key as keyof TContext;
         try {
-          (this[name] as unknown as Finalizable).finalize();
+          if (this[name]) {
+            (this[name] as unknown as Finalizable).finalize();
+          }
         } catch (e) {
           console.info(`Failed to finalize ${name}`);
           throw e;
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f549e859..73dee78 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -15,9 +15,10 @@
  * limitations under the License.
  */
 
-import {NumericChangeId, PatchSetNum} from '../../types/common';
 import {BehaviorSubject, Observable} from 'rxjs';
 import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -42,57 +43,44 @@
   patchNum?: PatchSetNum;
 }
 
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: RouterState = {};
+export class RouterModel implements Finalizable {
+  private readonly privateState$ = new BehaviorSubject<RouterState>({});
 
-const privateState$ = new BehaviorSubject<RouterState>(initialState);
+  readonly routerView$: Observable<GerritView | undefined>;
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
+  readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
+
+  readonly routerPatchNum$: Observable<PatchSetNum | undefined>;
+
+  constructor() {
+    this.routerView$ = this.privateState$.pipe(
+      map(state => state.view),
+      distinctUntilChanged()
+    );
+    this.routerChangeNum$ = this.privateState$.pipe(
+      map(state => state.changeNum),
+      distinctUntilChanged()
+    );
+    this.routerPatchNum$ = this.privateState$.pipe(
+      map(state => state.patchNum),
+      distinctUntilChanged()
+    );
+  }
+
+  finalize() {}
+
+  setState(state: RouterState) {
+    this.privateState$.next(state);
+  }
+
+  updateState(partial: Partial<RouterState>) {
+    this.privateState$.next({
+      ...this.privateState$.getValue(),
+      ...partial,
+    });
+  }
+
+  get routerState$(): Observable<RouterState> {
+    return this.privateState$;
+  }
 }
-
-export function _testOnly_setState(state: RouterState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const routerState$: Observable<RouterState> = privateState$;
-
-// Must only be used by the router service or whatever is in control of this
-// model.
-// TODO: Consider keeping params of type AppElementParams entirely in the state
-export function updateState(
-  view?: GerritView,
-  changeNum?: NumericChangeId,
-  patchNum?: PatchSetNum
-) {
-  privateState$.next({
-    ...privateState$.getValue(),
-    view,
-    changeNum,
-    patchNum,
-  });
-}
-
-export const routerView$ = routerState$.pipe(
-  map(state => state.view),
-  distinctUntilChanged()
-);
-
-export const routerChangeNum$ = routerState$.pipe(
-  map(state => state.changeNum),
-  distinctUntilChanged()
-);
-
-export const routerPatchNum$ = routerState$.pipe(
-  map(state => state.patchNum),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 1f9e083..e61ab15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
   config,
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
 } from './shortcuts-config';
-import {disableShortcuts$} from '../user/user-model';
 import {
   ComboKey,
   eventMatchesShortcut,
@@ -33,6 +33,7 @@
 } from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 import {Finalizable} from '../registry';
+import {UserModel} from '../user/user-model';
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
@@ -98,7 +99,10 @@
 
   private readonly subscriptions: Subscription[] = [];
 
-  constructor(readonly reporting?: ReportingService) {
+  constructor(
+    readonly userModel: UserModel,
+    readonly reporting?: ReportingService
+  ) {
     for (const section of config.keys()) {
       const items = config.get(section) ?? [];
       for (const item of items) {
@@ -106,11 +110,16 @@
       }
     }
     this.subscriptions.push(
-      disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x))
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+          distinctUntilChanged()
+        )
+        .subscribe(x => (this.shortcutsDisabled = x))
     );
     this.keydownListener = (e: KeyboardEvent) => {
       if (!isComboKey(e.key)) return;
-      if (this.shouldSuppress(e)) return;
+      if (this.shortcutsDisabled || shouldSuppress(e)) return;
       this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
     };
     document.addEventListener('keydown', this.keydownListener);
@@ -150,7 +159,12 @@
   addShortcut(
     element: HTMLElement,
     shortcut: Binding,
-    listener: (e: KeyboardEvent) => void
+    listener: (e: KeyboardEvent) => void,
+    options: {
+      shouldSuppress: boolean;
+    } = {
+      shouldSuppress: true,
+    }
   ) {
     const wrappedListener = (e: KeyboardEvent) => {
       if (e.repeat && !shortcut.allowRepeat) return;
@@ -160,19 +174,21 @@
       } else {
         if (this.isInComboKeyMode()) return;
       }
-      if (this.shouldSuppress(e)) return;
+      if (options.shouldSuppress && shouldSuppress(e)) return;
+      // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+      // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+      // the shortcut.
+      if (options.shouldSuppress && this.shortcutsDisabled) return;
       e.preventDefault();
       e.stopPropagation();
+      this.reportTriggered(e);
       listener(e);
     };
     element.addEventListener('keydown', wrappedListener);
     return () => element.removeEventListener('keydown', wrappedListener);
   }
 
-  shouldSuppress(e: KeyboardEvent) {
-    if (this.shortcutsDisabled) return true;
-    if (shouldSuppress(e)) return true;
-
+  private reportTriggered(e: KeyboardEvent) {
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -186,7 +202,6 @@
       from = e.currentTarget.tagName;
     }
     this.reporting?.reportInteraction('shortcut-triggered', {key, from});
-    return false;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index a024159..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,80 +21,18 @@
   ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {Key, Modifier} from '../../utils/dom-util';
-
-async function keyEventOn(
-  el: HTMLElement,
-  callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
-  key = 'k'
-): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
-  el.addEventListener('keydown', (e: KeyboardEvent) => {
-    callback(e);
-    resolve(e);
-  });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
-}
+import {getAppContext} from '../app-context';
 
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
 
   setup(() => {
-    service = new ShortcutsService();
-  });
-
-  suite('shouldSuppress', () => {
-    test('do not suppress shortcut event from <div>', async () => {
-      await keyEventOn(document.createElement('div'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <input>', async () => {
-      await keyEventOn(document.createElement('input'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <textarea>', async () => {
-      await keyEventOn(document.createElement('textarea'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('do not suppress shortcut event from checkbox <input>', async () => {
-      const inputEl = document.createElement('input');
-      inputEl.setAttribute('type', 'checkbox');
-      await keyEventOn(inputEl, e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress "enter" shortcut event from <a>', async () => {
-      await keyEventOn(document.createElement('a'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-      await keyEventOn(
-        document.createElement('a'),
-        e => assert.isTrue(service.shouldSuppress(e)),
-        13,
-        'enter'
-      );
-    });
+    service = new ShortcutsService(
+      getAppContext().userModel,
+      getAppContext().reportingService
+    );
   });
 
   test('getShortcut', () => {
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index f772ebe..441c09d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,101 +14,175 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
+import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {
+  DiffPreferencesInfo as DiffPreferencesInfoAPI,
+  DiffViewMode,
+} from '../../api/diff';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  PreferencesInfo,
+} from '../../types/common';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
 } from '../../constants/constants';
-import {DiffPreferencesInfo, DiffViewMode} from '../../api/diff';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../types/diff';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
 
-interface UserState {
+export interface UserState {
   /**
    * Keeps being defined even when credentials have expired.
    */
   account?: AccountDetailInfo;
   preferences: PreferencesInfo;
   diffPreferences: DiffPreferencesInfo;
+  capabilities?: AccountCapabilityInfo;
 }
 
-const initialState: UserState = {
-  preferences: createDefaultPreferences(),
-  diffPreferences: createDefaultDiffPrefs(),
-};
+export class UserModel implements Finalizable {
+  private readonly privateState$: BehaviorSubject<UserState> =
+    new BehaviorSubject({
+      preferences: createDefaultPreferences(),
+      diffPreferences: createDefaultDiffPrefs(),
+    });
 
-const privateState$ = new BehaviorSubject(initialState);
+  readonly account$: Observable<AccountDetailInfo | undefined> = select(
+    this.privateState$,
+    userState => userState.account
+  );
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
+  /** Note that this may still be true, even if credentials have expired. */
+  readonly loggedIn$: Observable<boolean> = select(
+    this.account$,
+    account => !!account
+  );
+
+  readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+    select(this.userState$, userState => userState.capabilities);
+
+  readonly isAdmin$: Observable<boolean> = select(
+    this.capabilities$,
+    capabilities => capabilities?.administrateServer ?? false
+  );
+
+  readonly preferences$: Observable<PreferencesInfo> = select(
+    this.privateState$,
+    userState => userState.preferences
+  );
+
+  readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
+    this.privateState$,
+    userState => userState.diffPreferences
+  );
+
+  readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
+    this.preferences$,
+    preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
+  );
+
+  private subscriptions: Subscription[] = [];
+
+  get userState$(): Observable<UserState> {
+    return this.privateState$;
+  }
+
+  constructor(readonly restApiService: RestApiService) {
+    this.subscriptions = [
+      from(this.restApiService.getAccount()).subscribe(
+        (account?: AccountDetailInfo) => {
+          this.setAccount(account);
+        }
+      ),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultPreferences());
+            return from(this.restApiService.getPreferences());
+          })
+        )
+        .subscribe((preferences?: PreferencesInfo) => {
+          this.setPreferences(preferences ?? createDefaultPreferences());
+        }),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultDiffPrefs());
+            return from(this.restApiService.getDiffPreferences());
+          })
+        )
+        .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
+          this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+        }),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(undefined);
+            return from(this.restApiService.getAccountCapabilities());
+          })
+        )
+        .subscribe((capabilities?: AccountCapabilityInfo) => {
+          this.setCapabilities(capabilities);
+        }),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+
+  updatePreferences(prefs: Partial<PreferencesInfo>) {
+    this.restApiService
+      .savePreferences(prefs)
+      .then((newPrefs: PreferencesInfo | undefined) => {
+        if (!newPrefs) return;
+        this.setPreferences(newPrefs);
+      });
+  }
+
+  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+    return this.restApiService
+      .saveDiffPreferences(diffPrefs)
+      .then((response: Response) => {
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as DiffPreferencesInfo;
+          if (!newPrefs) return;
+          this.setDiffPreferences(newPrefs);
+        });
+      });
+  }
+
+  getDiffPreferences() {
+    return this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this.setDiffPreferences(prefs);
+    });
+  }
+
+  setPreferences(preferences: PreferencesInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, preferences});
+  }
+
+  setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, diffPreferences});
+  }
+
+  setCapabilities(capabilities?: AccountCapabilityInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, capabilities});
+  }
+
+  private setAccount(account?: AccountDetailInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, account});
+  }
 }
-
-export function _testOnly_setState(state: UserState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const userState$: Observable<UserState> = privateState$;
-
-export function updateAccount(account?: AccountDetailInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, account});
-}
-
-export function updatePreferences(preferences: PreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, preferences});
-}
-
-export function updateDiffPreferences(diffPreferences: DiffPreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, diffPreferences});
-}
-
-export const account$ = userState$.pipe(
-  map(userState => userState.account),
-  distinctUntilChanged()
-);
-
-/** Note that this may still be true, even if credentials have expired. */
-export const loggedIn$ = account$.pipe(
-  map(account => !!account),
-  distinctUntilChanged()
-);
-
-export const preferences$ = userState$.pipe(
-  map(userState => userState.preferences),
-  distinctUntilChanged()
-);
-
-export const diffPreferences$ = userState$.pipe(
-  map(userState => userState.diffPreferences),
-  distinctUntilChanged()
-);
-
-export const preferenceDiffViewMode$ = preferences$.pipe(
-  map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
-  distinctUntilChanged()
-);
-
-export const myTopMenuItems$ = preferences$.pipe(
-  map(preferences => preferences?.my ?? []),
-  distinctUntilChanged()
-);
-
-export const sizeBarInChangeTable$ = preferences$.pipe(
-  map(prefs => !!prefs?.size_bar_in_change_table),
-  distinctUntilChanged()
-);
-
-export const disableShortcuts$ = preferences$.pipe(
-  map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
deleted file mode 100644
index d2bca85..0000000
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {from, of, Subscription} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {
-  account$,
-  updateAccount,
-  updatePreferences,
-  updateDiffPreferences,
-} from './user-model';
-import {
-  createDefaultPreferences,
-  createDefaultDiffPrefs,
-} from '../../constants/constants';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../registry';
-
-export class UserService implements Finalizable {
-  private readonly subscriptions: Subscription[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    from(this.restApiService.getAccount()).subscribe(
-      (account?: AccountDetailInfo) => {
-        updateAccount(account);
-      }
-    );
-    this.subscriptions.push(
-      account$
-        .pipe(
-          switchMap(account => {
-            if (!account) return of(createDefaultPreferences());
-            return from(this.restApiService.getPreferences());
-          })
-        )
-        .subscribe((preferences?: PreferencesInfo) => {
-          updatePreferences(preferences ?? createDefaultPreferences());
-        })
-    );
-    this.subscriptions.push(
-      account$
-        .pipe(
-          switchMap(account => {
-            if (!account) return of(createDefaultDiffPrefs());
-            return from(this.restApiService.getDiffPreferences());
-          })
-        )
-        .subscribe((diffPrefs?: DiffPreferencesInfo) => {
-          updateDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
-        })
-    );
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  updatePreferences(prefs: Partial<PreferencesInfo>) {
-    this.restApiService
-      .savePreferences(prefs)
-      .then((newPrefs: PreferencesInfo | undefined) => {
-        if (!newPrefs) return;
-        updatePreferences(newPrefs);
-      });
-  }
-
-  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
-    return this.restApiService
-      .saveDiffPreferences(diffPrefs)
-      .then((response: Response) => {
-        this.restApiService.getResponseObject(response).then(obj => {
-          const newPrefs = obj as unknown as DiffPreferencesInfo;
-          if (!newPrefs) return;
-          updateDiffPreferences(newPrefs);
-        });
-      });
-  }
-
-  getDiffPreferences() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      updateDiffPreferences(prefs);
-    });
-  }
-}
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index f8fd534..94e3f69 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -21,12 +21,11 @@
 import '../scripts/bundled-polymer';
 import '@polymer/iron-test-helpers/iron-test-helpers';
 import './test-router';
-import {
-  _testOnlyInitAppContext,
-  _testOnlyFinalizeAppContext,
-} from './test-app-context-init';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {createTestAppContext} from './test-app-context-init';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
 import {
   cleanupTestUtils,
   getCleanupsCount,
@@ -36,7 +35,6 @@
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
 import 'chai/chai';
 import {chaiDomDiff} from '@open-wc/semantic-dom-diff';
@@ -46,14 +44,6 @@
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {updatePreferences} from '../services/user/user-model';
-import {createDefaultPreferences} from '../constants/constants';
-import {getAppContext} from '../services/app-context';
-import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
-import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
-import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
-import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
-import {_testOnly_resetState as resetUserState} from '../services/user/user-model';
 
 declare global {
   interface Window {
@@ -104,6 +94,7 @@
 
 window.fixture = fixtureImpl;
 let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
@@ -112,19 +103,13 @@
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
-  _testOnlyInitAppContext();
+  appContext = createTestAppContext();
+  injectAppContext(appContext);
   // The following calls is nessecary to avoid influence of previously executed
   // tests.
-  initGlobalVariables();
-  _testOnly_initGerritPluginApi();
+  initGlobalVariables(appContext);
 
-  resetChangeState();
-  resetChecksState();
-  resetCommentsState();
-  resetRouterState();
-  resetUserState();
-
-  const shortcuts = getAppContext().shortcutsService;
+  const shortcuts = appContext.shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
@@ -220,8 +205,7 @@
   cancelAllTasks();
   cleanUpStorage();
   // Reset state
-  updatePreferences(createDefaultPreferences());
-  _testOnlyFinalizeAppContext();
+  appContext?.finalize();
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 5afdeb7..3c6d4af 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -276,9 +276,6 @@
   getDiff(): Promise<DiffInfo | undefined> {
     throw new Error('getDiff() not implemented by RestApiMock.');
   },
-  getDiffChangeDetail(): Promise<ChangeInfo | undefined | null> {
-    throw new Error('getDiffChangeDetail() not implemented by RestApiMock.');
-  },
   getDiffComments() {
     // NOTE: This method can not be typed properly due to overloads.
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 03c5967..5f5507b 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -18,26 +18,26 @@
 // Init app context before any other imports
 import {create, Registry, Finalizable} from '../services/registry';
 import {assertIsDefined} from '../utils/common-util';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {AppContext} from '../services/app-context';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
 import {grStorageMock} from '../services/storage/gr-storage_mock';
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 import {FlagsServiceImplementation} from '../services/flags/flags_impl';
 import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeService} from '../services/change/change-service';
-import {ChecksService} from '../services/checks/checks-service';
+import {ChangeModel} from '../services/change/change-model';
+import {ChecksModel} from '../services/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {ConfigService} from '../services/config/config-service';
-import {UserService} from '../services/user/user-service';
-import {CommentsService} from '../services/comments/comments-service';
+import {UserModel} from '../services/user/user-model';
+import {CommentsModel} from '../services/comments/comments-model';
+import {RouterModel} from '../services/router/router-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {BrowserModel} from '../services/browser/browser-model';
+import {ConfigModel} from '../services/config/config-model';
 
-let appContext: (AppContext & Finalizable) | undefined;
-
-export function _testOnlyInitAppContext() {
+export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
@@ -47,42 +47,61 @@
       return new GrAuthMock(ctx.eventEmitter);
     },
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
-    changeService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+    changeModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ChangeModel(routerModel, restApiService);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
+    commentsModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      assertIsDefined(reportingService, 'reportingService');
+      return new CommentsModel(
+        routerModel,
+        changeModel,
+        restApiService,
+        reportingService
+      );
     },
-    checksService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksService(ctx.reportingService!);
+    checksModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(reportingService, 'reportingService');
+      return new ChecksModel(routerModel, changeModel, reportingService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
       return new GrJsApiInterface(ctx.reportingService!);
     },
     storageService: (_ctx: Partial<AppContext>) => grStorageMock,
-    configService: (ctx: Partial<AppContext>) => {
+    configModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigService(ctx.restApiService!);
+      return new ConfigModel(ctx.changeModel!, ctx.restApiService!);
     },
-    userService: (ctx: Partial<AppContext>) => {
+    userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserService(ctx.restApiService!);
+      return new UserModel(ctx.restApiService!);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel!, ctx.reportingService!);
     },
-    browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+    browserModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
+      return new BrowserModel(ctx.userModel!);
+    },
   };
-  appContext = create<AppContext>(appRegistry);
-  injectAppContext(appContext);
-}
-
-export function _testOnlyFinalizeAppContext() {
-  appContext?.finalize();
-  appContext = undefined;
+  return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 87760de..da4601b 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {
   AccountDetailInfo,
   AccountId,
@@ -31,6 +30,7 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommitId,
@@ -62,6 +62,9 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RobotCommentInfo,
+  RobotId,
+  RobotRunId,
   SchemesInfoMap,
   ServerInfo,
   SubmittedTogetherInfo,
@@ -96,10 +99,9 @@
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
 import {
+  CommentThread,
   createCommentThreads,
-  UIComment,
-  UIDraft,
-  UIHuman,
+  DraftInfo,
 } from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
@@ -504,7 +506,9 @@
   };
 }
 
-export function createComment(): UIHuman {
+export function createComment(
+  extra: Partial<CommentInfo | DraftInfo> = {}
+): CommentInfo {
   return {
     patch_set: 1 as PatchSetNum,
     id: '12345' as UrlEncodedCommentId,
@@ -514,15 +518,28 @@
     updated: '2018-02-13 22:48:48.018000000' as Timestamp,
     unresolved: false,
     path: 'abc.txt',
+    ...extra,
   };
 }
 
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    collapsed: false,
     __draft: true,
-    __editing: false,
+    ...extra,
+  };
+}
+
+export function createRobotComment(
+  extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+  return {
+    ...createComment(),
+    robot_id: 'robot-id-123' as RobotId,
+    robot_run_id: 'robot-run-id-456' as RobotRunId,
+    properties: {},
+    fix_suggestions: [],
+    ...extra,
   };
 }
 
@@ -629,14 +646,27 @@
   return new ChangeComments(comments, {}, drafts, {}, {});
 }
 
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+  ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+  return {
+    comments: comments.map(c => createComment(c)),
+    rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+    path: 'test-path-comment-thread',
+    commentSide: CommentSide.REVISION,
+    patchNum: 1 as PatchSetNum,
+    line: 314,
+  };
+}
+
+export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
-  comments = comments.map(comment => {
+  const filledComments = comments.map(comment => {
     return {...createComment(), ...comment};
   });
-  const threads = createCommentThreads(comments);
+  const threads = createCommentThreads(filledComments);
   return threads[0];
 }
 
@@ -711,6 +741,7 @@
     name: 'Verified',
     status: SubmitRequirementStatus.SATISFIED,
     submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    is_legacy: false,
   };
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 2b57e98..ebeb1db 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,8 +23,8 @@
 import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
-import {UserService} from '../services/user/user-service';
+import {CommentsModel} from '../services/comments/comments-model';
+import {UserModel} from '../services/user/user-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
@@ -112,12 +112,12 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubComments<K extends keyof CommentsService>(method: K) {
-  return sinon.stub(getAppContext().commentsService, method);
+export function stubComments<K extends keyof CommentsModel>(method: K) {
+  return sinon.stub(getAppContext().commentsModel, method);
 }
 
-export function stubUsers<K extends keyof UserService>(method: K) {
-  return sinon.stub(getAppContext().userService, method);
+export function stubUsers<K extends keyof UserModel>(method: K) {
+  return sinon.stub(getAppContext().userModel, method);
 }
 
 export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
@@ -192,13 +192,14 @@
   const start = Date.now();
   let sleep = 0;
   if (predicate()) return Promise.resolve();
+  const error = new Error(message);
   return new Promise((resolve, reject) => {
     const waiter = () => {
       if (predicate()) {
         return resolve();
       }
       if (Date.now() - start >= 1000) {
-        return reject(new Error(message));
+        return reject(error);
       }
       setTimeout(waiter, sleep);
       sleep = sleep === 0 ? 1 : sleep * 4;
@@ -218,21 +219,33 @@
  *   await listenOnce(el, 'render');
  *   ...
  */
-export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise<void>(resolve => {
-    const listener = () => {
+export function listenOnce<T extends Event>(
+  el: EventTarget,
+  eventType: string
+) {
+  return new Promise<T>(resolve => {
+    const listener = (e: Event) => {
       removeEventListener();
-      resolve();
+      resolve(e as T);
     };
-    el.addEventListener(eventType, listener);
     let removeEventListener = () => {
       el.removeEventListener(eventType, listener);
       removeEventListener = () => {};
     };
+    el.addEventListener(eventType, listener);
     registerTestCleanup(removeEventListener);
   });
 }
 
+export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
+  const eventOptions = {
+    detail,
+    bubbles: true,
+    composed: true,
+  };
+  element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
+}
+
 export function pressKey(
   element: HTMLElement,
   key: string | Key,
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index fc38756..f58abb3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -692,9 +692,10 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
 export interface CommentInfo {
-  // TODO(TS): Make this required.
-  patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: PatchSetNum;
   path?: string;
   side?: CommentSide;
   parent?: number;
@@ -702,7 +703,6 @@
   range?: CommentRange;
   in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f467cf6..4f24535 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {Comment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
@@ -168,7 +168,7 @@
 
 export interface OpenFixPreviewEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
@@ -178,7 +178,7 @@
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
 export interface CreateFixCommentEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
 
@@ -249,3 +249,12 @@
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index b90b12f..42f3d45 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -186,7 +186,6 @@
   patchRange: PatchRange | null;
   selectedFileIndex: number;
   showReplyDialog: boolean;
-  showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
 }
@@ -198,7 +197,6 @@
   selectedFileIndex?: number;
   selectedChangeIndex?: number;
   showReplyDialog?: boolean;
-  showDownloadDialog?: boolean;
   diffMode?: DiffViewMode;
   numFilesShown?: number;
   scrollTop?: number;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..966a75c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -30,84 +30,116 @@
   AccountInfo,
   AccountDetailInfo,
 } from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
-import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
 import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
+import {LineNumber} from '../api/diff';
 
 export interface DraftCommentProps {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
+  // This must be true for all drafts. Drafts received from the backend will be
+  // modified immediately with __draft:true before allowing them to get into
+  // the application state.
+  __draft: boolean;
 }
 
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  collapsed?: boolean;
+export interface UnsavedCommentProps {
+  // This must be true for all unsaved comment drafts. An unsaved draft is
+  // always just local to a comment component like <gr-comment> or
+  // <gr-comment-thread>. Unsaved drafts will never appear in the application
+  // state.
+  __unsaved: boolean;
 }
 
-export interface UIStateDraftProps {
-  __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
 
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
 
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
 
 export type CommentMap = {[path: string]: boolean};
 
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
   x: T | DraftInfo | RobotCommentInfo | undefined
 ): x is RobotCommentInfo {
   return !!x && !!(x as RobotCommentInfo).robot_id;
 }
 
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+  x: T | DraftInfo | undefined
+): x is DraftInfo {
+  return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+  x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+  return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+  x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+  return isDraft(x) || isUnsaved(x);
 }
 
 interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
+  updated: Timestamp;
+  id: UrlEncodedCommentId;
 }
 
 export function sortComments<T extends SortableComment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
+    const d1 = isDraft(c1);
+    const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+    const date1 = parseDate(c1.updated);
+    const date2 = parseDate(c2.updated);
     const dateDiff = date1!.valueOf() - date2!.valueOf();
     if (dateDiff !== 0) return dateDiff;
 
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
+    const id1 = c1.id;
+    const id2 = c2.id;
     return id1.localeCompare(id2);
   });
 }
 
-export function createCommentThreads(
-  comments: UIComment[],
-  patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+  return {
+    path: thread.path,
+    patch_set: thread.patchNum,
+    side: thread.commentSide ?? CommentSide.REVISION,
+    line: typeof thread.line === 'number' ? thread.line : undefined,
+    range: thread.range,
+    parent: thread.mergeParentNum,
+    message: '',
+    unresolved: true,
+    __unsaved: true,
+  };
+}
+
+export function createUnsavedReply(
+  replyingTo: CommentInfo,
+  message: string,
+  unresolved: boolean
+): UnsavedInfo {
+  return {
+    path: replyingTo.path,
+    patch_set: replyingTo.patch_set,
+    side: replyingTo.side,
+    line: replyingTo.line,
+    range: replyingTo.range,
+    parent: replyingTo.parent,
+    in_reply_to: replyingTo.id,
+    message,
+    unresolved,
+    __unsaved: true,
+  };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +161,6 @@
     const newThread: CommentThread = {
       comments: [comment],
       patchNum: comment.patch_set,
-      diffSide: Side.LEFT,
       commentSide: comment.side ?? CommentSide.REVISION,
       mergeParentNum: comment.parent,
       path: comment.path,
@@ -137,13 +168,6 @@
       range: comment.range,
       rootId: comment.id,
     };
-    if (patchRange) {
-      if (isInBaseOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.LEFT;
-      else if (isInRevisionOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.RIGHT;
-      else throw new Error('comment does not belong in given patchrange');
-    }
     if (!comment.line && !comment.range) {
       newThread.line = 'FILE';
     }
@@ -154,68 +178,98 @@
 }
 
 export interface CommentThread {
-  comments: UIComment[];
+  /**
+   * This can only contain at most one draft. And if so, then it is the last
+   * comment in this list. This must not contain unsaved drafts.
+   */
+  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+  /**
+   * Identical to the id of the first comment. If this is undefined, then the
+   * thread only contains an unsaved draft.
+   */
+  rootId?: UrlEncodedCommentId;
   path: string;
   commentSide: CommentSide;
   /* mergeParentNum is the merge parent number only valid for merge commits
      when commentSide is PARENT.
      mergeParentNum is undefined for auto merge commits
+     Same as `parent` in CommentInfo.
   */
   mergeParentNum?: number;
   patchNum?: PatchSetNum;
+  /* Different from CommentInfo, which just keeps the line undefined for
+     FILE comments. */
   line?: LineNumber;
-  /* rootId is optional since we create a empty comment thread element for
-     drafts and then create the draft which becomes the root */
-  rootId?: UrlEncodedCommentId;
-  diffSide?: Side;
   range?: CommentRange;
   ported?: boolean; // is the comment ported over from a previous patchset
   rangeInfoLost?: boolean; // if BE was unable to determine a range for this
 }
 
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
-  const len = thread?.comments.length;
-  return thread && len ? thread.comments[len - 1] : undefined;
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+  const len = thread.comments.length;
+  return thread.comments[len - 1];
 }
 
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
-  return thread?.comments?.[0];
+export function getLastPublishedComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+  const len = publishedComments.length;
+  return publishedComments[len - 1];
 }
 
-export function countComments(thread?: CommentThread) {
-  return thread?.comments?.length ?? 0;
+export function getFirstComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  return thread.comments[0];
 }
 
-export function isPatchsetLevel(thread?: CommentThread): boolean {
-  return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function countComments(thread: CommentThread) {
+  return thread.comments.length;
 }
 
-export function isUnresolved(thread?: CommentThread): boolean {
+export function isPatchsetLevel(thread: CommentThread): boolean {
+  return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
   return !isResolved(thread);
 }
 
-export function isResolved(thread?: CommentThread): boolean {
-  return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+  const lastUnresolved = getLastComment(thread)?.unresolved;
+  return !lastUnresolved ?? false;
 }
 
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
 
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isRobotThread(thread: CommentThread): boolean {
   return isRobot(getFirstComment(thread));
 }
 
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
 
+export function lastUpdated(thread: CommentThread): Date | undefined {
+  // We don't want to re-sort comments when you save a draft reply, so
+  // we stick to the timestampe of the last *published* comment.
+  const lastUpdated =
+    getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+  return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
 /**
  * Whether the given comment should be included in the base side of the
  * given patch range.
  */
 export function isInBaseOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+    parent?: number;
+  },
   range: PatchRange
 ) {
   // If the base of the patch range is a parent of a merge, and the comment
@@ -249,7 +303,10 @@
  * given patch range.
  */
 export function isInRevisionOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+  },
   range: PatchRange
 ) {
   return (
@@ -271,7 +328,7 @@
 }
 
 export function getPatchRangeForCommentUrl(
-  comment: UIComment,
+  comment: Comment,
   latestPatchNum: RevisionPatchSetNum
 ) {
   if (!comment.patch_set) throw new Error('Missing comment.patch_set');
@@ -279,7 +336,7 @@
   // TODO(dhruvsri): Add handling for comment left on parents of merge commits
   if (comment.side === CommentSide.PARENT) {
     if (comment.patch_set === ParentPatchSetNum)
-      throw new Error('diffSide cannot be PARENT');
+      throw new Error('comment.patch_set cannot be PARENT');
     return {
       patchNum: comment.patch_set as RevisionPatchSetNum,
       basePatchNum: ParentPatchSetNum,
@@ -355,30 +412,46 @@
   return authors;
 }
 
-export function computeId(comment: UIComment) {
-  if (comment.id) return comment.id;
-  if (isDraft(comment)) return comment.__draftID;
-  throw new Error('Missing id in root comment.');
-}
-
 /**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
  */
 export function addPath<T>(comments: {[path: string]: T[]} = {}): {
   [path: string]: Array<T & {path: string}>;
 } {
   const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
   for (const filePath of Object.keys(comments)) {
-    const allCommentsForPath = comments[filePath] || [];
-    if (allCommentsForPath.length) {
-      updatedComments[filePath] = allCommentsForPath.map(comment => {
-        return {...comment, path: filePath};
-      });
-    }
+    updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+      return {...comment, path: filePath};
+    });
   }
   return updatedComments;
 }
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+  draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+  const updated: {[path: string]: DraftInfo[]} = {};
+  for (const filePath of Object.keys(draftsByPath)) {
+    updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+      return {...draft, __draft: true};
+    });
+  }
+  return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+  return {
+    id: comment?.id,
+    message_length: comment?.message?.trim().length,
+    in_reply_to: comment?.in_reply_to,
+    unresolved: comment?.unresolved,
+    path_length: comment?.path?.length,
+    line: comment?.range?.start_line ?? comment?.line,
+    unsaved: isUnsaved(comment),
+  };
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..f5a2177 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -23,9 +23,8 @@
   sortComments,
 } from './comment-util';
 import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide} from '../constants/constants';
 import {
-  BasePatchSetNum,
   ParentPatchSetNum,
   PatchSetNum,
   RevisionPatchSetNum,
@@ -37,7 +36,6 @@
   test('isUnresolved', () => {
     const thread = createCommentThread([createComment()]);
 
-    assert.isFalse(isUnresolved(undefined));
     assert.isFalse(isUnresolved(thread));
 
     assert.isTrue(
@@ -97,7 +95,6 @@
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        diffSide: Side.LEFT,
         __draft: true,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
       },
@@ -106,13 +103,11 @@
         message: 'i like you, jack',
         updated: '2015-12-23 15:00:20.396000000' as Timestamp,
         line: 1,
-        diffSide: Side.LEFT,
       },
       {
         id: 'jacks_reply' as UrlEncodedCommentId,
         message: 'i like you, too',
         updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-        diffSide: Side.LEFT,
         line: 1,
         in_reply_to: 'sallys_confession',
       },
@@ -153,21 +148,16 @@
         },
       ];
 
-      const actualThreads = createCommentThreads(comments, {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 4 as RevisionPatchSetNum,
-      });
+      const actualThreads = createCommentThreads(comments);
 
       assert.equal(actualThreads.length, 2);
 
-      assert.equal(actualThreads[0].diffSide, Side.LEFT);
       assert.equal(actualThreads[0].comments.length, 2);
       assert.deepEqual(actualThreads[0].comments[0], comments[0]);
       assert.deepEqual(actualThreads[0].comments[1], comments[1]);
       assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
       assert.equal(actualThreads[0].line, 1);
 
-      assert.equal(actualThreads[1].diffSide, Side.LEFT);
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
@@ -194,7 +184,6 @@
 
       const expectedThreads = [
         {
-          diffSide: Side.LEFT,
           commentSide: CommentSide.REVISION,
           path: '/p',
           rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -226,13 +215,7 @@
         },
       ];
 
-      assert.deepEqual(
-        createCommentThreads(comments, {
-          basePatchNum: 5 as BasePatchSetNum,
-          patchNum: 10 as RevisionPatchSetNum,
-        }),
-        expectedThreads
-      );
+      assert.deepEqual(createCommentThreads(comments), expectedThreads);
     });
 
     test('does not thread unrelated comments at same location', () => {
@@ -241,14 +224,12 @@
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
           updated: '2015-12-23 15:00:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
         {
           id: 'jacks_reply' as UrlEncodedCommentId,
           message: 'i like you, too',
           updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
       ];
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index bd0f742..b96ebe6 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -390,29 +390,51 @@
   return true;
 }
 
-export function addGlobalShortcut(
-  shortcut: Binding,
-  listener: (e: KeyboardEvent) => void
-) {
-  return addShortcut(document.body, shortcut, listener);
+export interface ShortcutOptions {
+  /**
+   * Do you want to suppress events from <input> elements and such?
+   */
+  shouldSuppress?: boolean;
+  /**
+   * Do you want to take care of calling preventDefault() and
+   * stopPropagation() yourself?
+   */
+  doNotPrevent?: boolean;
 }
 
+export function addGlobalShortcut(
+  shortcut: Binding,
+  listener: (e: KeyboardEvent) => void,
+  options: ShortcutOptions = {
+    shouldSuppress: true,
+    doNotPrevent: false,
+  }
+) {
+  return addShortcut(document.body, shortcut, listener, options);
+}
+
+/**
+ * Deprecated.
+ *
+ * For LitElement use the shortcut-controller.
+ * For PolymerElement use the keyboard-shortcut-mixin.
+ */
 export function addShortcut(
   element: HTMLElement,
   shortcut: Binding,
   listener: (e: KeyboardEvent) => void,
-  options: {
-    shouldSuppress: boolean;
-  } = {
+  options: ShortcutOptions = {
     shouldSuppress: false,
+    doNotPrevent: false,
   }
 ) {
   const wrappedListener = (e: KeyboardEvent) => {
     if (e.repeat && !shortcut.allowRepeat) return;
     if (options.shouldSuppress && shouldSuppress(e)) return;
-    if (eventMatchesShortcut(e, shortcut)) {
-      listener(e);
-    }
+    if (!eventMatchesShortcut(e, shortcut)) return;
+    if (!options.doNotPrevent) e.preventDefault();
+    if (!options.doNotPrevent) e.stopPropagation();
+    listener(e);
   };
   element.addEventListener('keydown', wrappedListener);
   return () => element.removeEventListener('keydown', wrappedListener);
@@ -454,7 +476,10 @@
     // Suppress shortcuts if the key is 'enter'
     // and target is an anchor or button or paper-tab.
     (e.keyCode === 13 &&
-      (tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
+      (tagName === 'A' ||
+        tagName === 'BUTTON' ||
+        tagName === 'GR-BUTTON' ||
+        tagName === 'PAPER-TAB'))
   ) {
     return true;
   }
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index e139805..28157d9 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -326,6 +326,15 @@
       });
     });
 
+    test('suppress "enter" shortcut event from <gr-button>', async () => {
+      await keyEventOn(
+        document.createElement('gr-button'),
+        e => assert.isTrue(shouldSuppress(e)),
+        13,
+        'enter'
+      );
+    });
+
     test('suppress "enter" shortcut event from <a>', async () => {
       await keyEventOn(document.createElement('a'), e => {
         assert.isFalse(shouldSuppress(e));
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 0aa4a51..c154958 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -20,6 +20,7 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
+import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -224,12 +225,16 @@
 }
 
 export function extractAssociatedLabels(
-  requirement: SubmitRequirementResultInfo
+  requirement: SubmitRequirementResultInfo,
+  type: 'all' | 'onlyOverride' | 'onlySubmittability' = 'all'
 ): string[] {
-  let labels = extractLabelsFrom(
-    requirement.submittability_expression_result.expression
-  );
-  if (requirement.override_expression_result) {
+  let labels: string[] = [];
+  if (type !== 'onlyOverride') {
+    labels = labels.concat(
+      extractLabelsFrom(requirement.submittability_expression_result.expression)
+    );
+  }
+  if (requirement.override_expression_result && type !== 'onlySubmittability') {
     labels = labels.concat(
       extractLabelsFrom(requirement.override_expression_result.expression)
     );
@@ -258,20 +263,9 @@
  * If there is at least one non-legacy requirement, filter legacy requirements.
  */
 export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
-  let submit_requirements = (change?.submit_requirements ?? []).filter(
-    req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
-  );
-
-  const hasNonLegacyRequirements = submit_requirements.some(
-    req => req.is_legacy === false
-  );
-  if (hasNonLegacyRequirements) {
-    submit_requirements = submit_requirements.filter(
-      req => req.is_legacy === false
-    );
-  }
-
-  return submit_requirements;
+  return (change?.submit_requirements ?? [])
+    .filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE)
+    .filter(req => req.is_legacy === false);
 }
 
 // TODO(milutin): This may be temporary for demo purposes
@@ -305,3 +299,16 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
+
+export function showNewSubmitRequirements(
+  flagsService: FlagsService,
+  change?: ParsedChangeInfo | ChangeInfo
+) {
+  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
+    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+  );
+  if (!isSubmitRequirementsUiEnabled) return false;
+  if ((getRequirements(change) ?? []).length === 0) return false;
+
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 6cb04af..1b8ad0c 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -298,7 +298,7 @@
         is_legacy: true,
       };
       const change = createChangeInfoWith([requirement]);
-      assert.deepEqual(getRequirements(change), [requirement]);
+      assert.deepEqual(getRequirements(change), []);
     });
     test('legacy and non-legacy - filter legacy', () => {
       const requirement = {
@@ -313,10 +313,7 @@
       assert.deepEqual(getRequirements(change), [requirement2]);
     });
     test('filter not applicable', () => {
-      const requirement = {
-        ...createSubmitRequirementResultInfo(),
-        is_legacy: true,
-      };
+      const requirement = createSubmitRequirementResultInfo();
       const requirement2 = {
         ...createSubmitRequirementResultInfo(),
         status: SubmitRequirementStatus.NOT_APPLICABLE,
@@ -348,6 +345,7 @@
               ...createSubmitRequirementExpressionInfo(),
               expression: `label:${triggerVote}=MAX`,
             },
+            is_legacy: false,
           },
         ],
         labels: {
diff --git a/polygerrit-ui/app/utils/math-util.ts b/polygerrit-ui/app/utils/math-util.ts
new file mode 100644
index 0000000..adec7d3
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @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.
+ */
+
+/**
+ * Returns a random integer between `from` and `to`, both included.
+ * So getRandomInt(0, 2) returns 0, 1, or 2 each with probability 1/3.
+ */
+export function getRandomInt(from: number, to: number) {
+  return Math.floor(Math.random() * (to + 1 - from) + from);
+}
diff --git a/polygerrit-ui/app/utils/math-util_test.ts b/polygerrit-ui/app/utils/math-util_test.ts
new file mode 100644
index 0000000..fca1d73
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma';
+import {getRandomInt} from './math-util';
+
+suite('math-util tests', () => {
+  test('getRandomInt', () => {
+    let r = 0;
+    const randomStub = sinon.stub(Math, 'random').callsFake(() => r);
+
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 0);
+    assert.equal(getRandomInt(0, 100), 0);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 10);
+    assert.equal(getRandomInt(10, 100), 10);
+
+    r = 0.999;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 2);
+    assert.equal(getRandomInt(0, 100), 100);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 12);
+    assert.equal(getRandomInt(10, 100), 100);
+
+    r = 0.5;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 1);
+    assert.equal(getRandomInt(0, 100), 50);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 11);
+    assert.equal(getRandomInt(10, 100), 55);
+
+    r = 0.0;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.33;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.34;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.66;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.67;
+    assert.equal(getRandomInt(0, 2), 2);
+    r = 0.99;
+    assert.equal(getRandomInt(0, 2), 2);
+
+    randomStub.restore();
+  });
+});
diff --git a/polygerrit-ui/app/utils/observable-util.ts b/polygerrit-ui/app/utils/observable-util.ts
new file mode 100644
index 0000000..e39aa48
--- /dev/null
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -0,0 +1,27 @@
+/**
+ * @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 {Observable} from 'rxjs';
+import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
+import {deepEqual} from './deep-util';
+
+export function select<A, B>(obs$: Observable<A>, mapper: (_: A) => B) {
+  return obs$.pipe(
+    map(mapper),
+    distinctUntilChanged(deepEqual),
+    shareReplay(1)
+  );
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 11717fb..c7b3d9e 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -29,7 +29,6 @@
   {@param? useGoogleFonts: ?}
   {@param? changeRequestsPath: ?}
   {@param? defaultChangeDetailHex: ?}
-  {@param? defaultDiffDetailHex: ?}
   {@param? defaultDashboardHex: ?}
   {@param? dashboardQuery: ?}
   {@param? userIsAuthenticated: ?}
@@ -52,9 +51,6 @@
       {if $defaultChangeDetailHex}
         changePage: '{$defaultChangeDetailHex}',
       {/if}
-      {if $defaultDiffDetailHex}
-        diffPage: '{$defaultDiffDetailHex}',
-      {/if}
       {if $defaultDashboardHex}
         dashboardPage: '{$defaultDashboardHex}',
       {/if}
@@ -99,18 +95,11 @@
         <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
       {/if}
     {/if}
-    {if $defaultDiffDetailHex}
-      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {if $userIsAuthenticated}
-        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {/if}
-      <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
-    {/if}
-    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script" crossorigin="anonymous"/>
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {if $userIsAuthenticated}
-      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
   {/if}
   {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
diff --git a/tools/BUILD b/tools/BUILD
index 2726132..e7a5230 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -102,7 +102,7 @@
         "-Xep:AsyncFunctionReturnsNull:ERROR",
         "-Xep:AutoValueConstructorOrderChecker:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
-        # "-Xep:AutoValueImmutableFields:WARN",
+        "-Xep:AutoValueImmutableFields:ERROR",
         # "-Xep:AutoValueSubclassLeaked:WARN",
         "-Xep:BadAnnotationImplementation:ERROR",
         "-Xep:BadComparable:ERROR",
@@ -119,7 +119,7 @@
         "-Xep:CacheLoaderNull:ERROR",
         "-Xep:CannotMockFinalClass:ERROR",
         "-Xep:CanonicalDuration:ERROR",
-        # "-Xep:CatchAndPrintStackTrace:WARN",
+        "-Xep:CatchAndPrintStackTrace:ERROR",
         "-Xep:CatchFail:ERROR",
         "-Xep:ChainedAssertionLosesContext:ERROR",
         "-Xep:ChainingConstructorIgnoresParameter:ERROR",
@@ -151,7 +151,7 @@
         "-Xep:DeadException:ERROR",
         "-Xep:DeadThread:ERROR",
         "-Xep:DefaultCharset:ERROR",
-        # "-Xep:DefaultPackage:WARN",
+        "-Xep:DefaultPackage:ERROR",
         "-Xep:DepAnn:ERROR",
         "-Xep:DeprecatedVariable:ERROR",
         "-Xep:DiscardedPostfixExpression:ERROR",
@@ -170,7 +170,7 @@
         "-Xep:EmptyBlockTag:ERROR",
         "-Xep:EmptyCatch:ERROR",
         "-Xep:EmptySetMultibindingContributions:ERROR",
-        # "-Xep:EqualsGetClass:WARN",
+        "-Xep:EqualsGetClass:ERROR",
         "-Xep:EqualsHashCode:ERROR",
         "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:EqualsNaN:ERROR",
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 3695e16..dec5f67 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -25,6 +25,7 @@
 
 @RunWith(Suite.class)
 @Suite.SuiteClasses({%s})
+@SuppressWarnings("DefaultPackage")
 public class %s {}
 """