Merge "Submit Requirements - show new UI only when SR returned from back-end"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 240b0ed..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
 ----
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 9ca881d..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))
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/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/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/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/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/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-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/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_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-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f59fdec..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,10 +59,6 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {
-  changeComments$,
-  threads$,
-} from '../../../services/comments/comments-model';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -411,24 +401,54 @@
 
   private showAllChips = new Map<RunStatus | Category, boolean>();
 
+  private commentsModel = getAppContext().commentsModel;
+
   private userModel = getAppContext().userModel;
 
-  private checksService = getAppContext().checksService;
+  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,
+      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));
   }
 
@@ -560,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 31e7be7..e397117 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,
@@ -181,7 +181,6 @@
   fireTitleChange,
 } from '../../../utils/event-util';
 import {GerritView, routerView$} from '../../../services/router/router-model';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {
   debounce,
   DelayedTask,
@@ -192,20 +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 {
-  change$,
-  changeLoadingStatus$,
-  LoadingStatus,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -282,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.
    */
@@ -367,7 +351,7 @@
   _changeNum?: NumericChangeId;
 
   @property({type: Object})
-  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+  _diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
   @property({type: Boolean})
   _editingCommitMessage = false;
@@ -559,15 +543,6 @@
   })
   resolveWeblinks?: GeneratedWebLink[];
 
-  readonly restApiService = getAppContext().restApiService;
-
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
-
-  private readonly commentsService = getAppContext().commentsService;
-
-  private readonly shortcuts = getAppContext().shortcutsService;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -614,6 +589,25 @@
     ];
   }
 
+  // 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 commentsModel = getAppContext().commentsModel;
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
   private subscriptions: Subscription[] = [];
 
   private replyRefitTask?: DelayedTask;
@@ -633,7 +627,7 @@
   override ready() {
     super.ready();
     this.subscriptions.push(
-      aPluginHasRegistered$.subscribe(b => {
+      this.checksModel.aPluginHasRegistered$.subscribe(b => {
         this._showChecksTab = b;
       })
     );
@@ -643,7 +637,7 @@
       })
     );
     this.subscriptions.push(
-      drafts$.subscribe(drafts => {
+      this.commentsModel.drafts$.subscribe(drafts => {
         this._diffDrafts = {...drafts};
       })
     );
@@ -653,12 +647,12 @@
       })
     );
     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;
@@ -1530,7 +1524,7 @@
   }
 
   _computeReplyButtonLabel(
-    drafts?: {[path: string]: UIDraft[]},
+    drafts?: {[path: string]: DraftInfo[]},
     canStartReview?: boolean
   ) {
     if (drafts === undefined || canStartReview === undefined) {
@@ -1880,7 +1874,7 @@
     }
 
     const detailCompletes = until(
-      changeLoadingStatus$,
+      this.changeModel.changeLoadingStatus$,
       status => status === LoadingStatus.LOADED
     );
     const editCompletes = this._getEdit();
@@ -2213,13 +2207,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
         )
@@ -2329,7 +2323,7 @@
         return;
       }
       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 88be4b8..0e74cc6 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
@@ -32,10 +32,6 @@
 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';
@@ -90,6 +86,7 @@
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
+  RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
@@ -101,15 +98,12 @@
 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 {
-  LoadingStatus,
-  _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';
@@ -118,7 +112,6 @@
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
-  let pluginApi: GerritInternal;
 
   let navigateToChangeStub: SinonStubbedMember<
     typeof GerritNav.navigateToChange
@@ -168,8 +161,6 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -272,8 +263,6 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -348,7 +337,6 @@
   ];
 
   setup(() => {
-    pluginApi = _testOnly_initGerritPluginApi();
     // Since pluginEndpoints are global, must reset state.
     _testOnly_resetEndpoints();
     navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -372,7 +360,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',
@@ -920,11 +908,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'
       );
     });
@@ -1493,7 +1483,7 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1509,7 +1499,7 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1526,7 +1516,7 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1947,7 +1937,7 @@
   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(),
@@ -1978,7 +1968,7 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -2144,7 +2134,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 f286098..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,7 +83,6 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {changeComments$} from '../../../services/comments/comments-model';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {select} from '../../../utils/observable-util';
 
@@ -316,6 +315,8 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   private readonly browserModel = getAppContext().browserModel;
 
   private subscriptions: Subscription[] = [];
@@ -375,7 +376,7 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions = [
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
       }),
       this.browserModel.diffViewMode$.subscribe(
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-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 cedddec..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,12 +51,6 @@
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
 } from '../../../types/types';
-import {threads$} from '../../../services/comments/comments-model';
-import {
-  change$,
-  changeNum$,
-  repo$,
-} from '../../../services/change/change-model';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -100,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)
   );
 }
 
@@ -264,6 +251,11 @@
 
   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;
@@ -273,12 +265,12 @@
   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;
       })
     );
@@ -288,12 +280,12 @@
       })
     );
     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;
       })
     );
@@ -392,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_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-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 6521ad3..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
@@ -44,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,
@@ -57,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
@@ -149,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() {
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..0349b3f 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,507 @@
 
 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();
   }
 
-  _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;
+  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;
+        }
+      `,
+    ];
   }
 
-  _handleResolvedCommentsMessageClick() {
-    this.unresolvedOnly = !this.unresolvedOnly;
+  override render() {
+    return html`
+      ${this.renderDropdown()}
+      <div id="threads" part="threads">
+        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+      </div>
+    `;
   }
 
-  getSortDropdownEntires() {
+  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 ca53d28..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,7 +55,6 @@
   LabelNameToInfoMap,
   PatchSetNumber,
 } from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
 import {getAppContext} from '../../services/app-context';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
@@ -95,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() {
@@ -493,7 +487,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.checksModel.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -537,7 +531,7 @@
   @state()
   repoConfig?: ConfigInfo;
 
-  private changeService = getAppContext().changeService;
+  private changeModel = getAppContext().changeModel;
 
   private configModel = getAppContext().configModel;
 
@@ -625,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;
@@ -733,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)
     );
   }
@@ -1101,7 +1109,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.checksModel.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -1112,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/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-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-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 50d6b24..e527134 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
@@ -113,20 +113,10 @@
 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 {
-  diffPath$,
-  currentPatchNum$,
-  change$,
-  changeLoadingStatus$,
-  LoadingStatus,
-} 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';
 
@@ -368,14 +358,14 @@
   // Private but used in tests.
   readonly userModel = getAppContext().userModel;
 
-  private readonly changeService = getAppContext().changeService;
+  // 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;
 
@@ -387,11 +377,6 @@
 
   private subscriptions: Subscription[] = [];
 
-  constructor() {
-    super();
-    this._commentsService;
-  }
-
   override connectedCallback() {
     super.connectedCallback();
     this._throttledToggleFileReviewed = throttleWrap(_ =>
@@ -405,7 +390,7 @@
     });
 
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this._changeComments = changeComments;
       })
     );
@@ -421,7 +406,7 @@
       })
     );
     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;
@@ -433,12 +418,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$,
+      combineLatest([
+        this.changeModel.currentPatchNum$,
         routerView$,
-        diffPath$,
-        this.userModel.diffPreferences$
-      )
+        this.changeModel.diffPath$,
+        this.userModel.diffPreferences$,
+      ])
         .pipe(
           filter(
             ([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -453,7 +438,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) => {
@@ -1069,7 +1056,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');
@@ -1079,7 +1066,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 = {
@@ -1145,7 +1132,7 @@
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this.changeService.updatePath(undefined);
+    this.changeModel.updatePath(undefined);
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1169,11 +1156,15 @@
     }
 
     const promises: Promise<unknown>[] = [];
-    if (!this._change)
+    if (!this._change) {
       promises.push(
-        until(changeLoadingStatus$, status => status === LoadingStatus.LOADED)
+        until(
+          this.changeModel.changeLoadingStatus$,
+          status => status === LoadingStatus.LOADED
+        )
       );
-    promises.push(until(commentsLoading$, isFalse));
+    }
+    promises.push(until(this.commentsModel.commentsLoading$, isFalse));
     promises.push(
       this._getChangeEdit().then(edit => {
         if (edit) {
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 d47a03e..4c4361d 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
@@ -31,8 +31,6 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {Side} from '../../../api/diff.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');
 
@@ -93,7 +91,7 @@
       ]});
       await flush();
 
-      setCommentState({
+      element.commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {},
@@ -139,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': [
               {
@@ -220,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': [
                 {
@@ -248,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',
@@ -272,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': [
                 {
@@ -300,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',
@@ -352,7 +353,7 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
-      setCommentState({
+      element.commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             {
@@ -381,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,
@@ -1200,7 +1202,9 @@
         manual_review: true,
       };
       element.userModel.setDiffPreferences(diffPreferences);
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
@@ -1237,7 +1241,8 @@
             manual_review: false,
           };
           element.userModel.setDiffPreferences(diffPreferences);
-          setChangeModelState({change: createChange(),
+          element.changeModel.setState({
+            change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
           setRouterModelState({
@@ -1262,7 +1267,9 @@
       sinon.stub(element.$.diffHost, 'reload');
 
       element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
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 81be728..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
@@ -211,10 +211,8 @@
     return Promise.all(promises);
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getChangeDetail(changeNum).then(change => {
-      this._change = change;
-    });
+  async _getChangeDetail(changeNum: NumericChangeId) {
+    this._change = await this.restApiService.getChangeDetail(changeNum);
   }
 
   _editChange(value?: ParsedChangeInfo | null) {
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 f276051..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';
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/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 9b11bc6..de8a02d 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,265 +19,636 @@
 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), {
-        doNotPrevent: true,
-        shouldSuppress: true,
-      })
-    );
-    this.cleanups.push(
-      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e), {
-        doNotPrevent: true,
-        shouldSuppress: true,
-      })
-    );
-    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')) {
+      if (!this.editing) {
+        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)) {
@@ -289,83 +660,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) {
@@ -379,413 +688,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..ab08996 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,334 @@
  * 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';
 
 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}`);
+    });
+  });
+
+  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 0c58c33..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}, () => this._handleEsc())
+  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,753 +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() {
-    if (!this._messageText.length) {
-      this._handleCancel();
-    }
-  }
-
-  _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() {
-    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 e0bda4a..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,1628 +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();
-      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();
-      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();
     });
 
@@ -1645,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-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-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_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-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 98%
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 0ce7f0d..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
 {
@@ -2276,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 8b44242..b602a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -424,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/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 17e0994..9b3e75d 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -16,7 +16,7 @@
  */
 
 import {create, Registry, Finalizable} from '../services/registry';
-import {AppContext, injectAppContext} from '../services/app-context';
+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';
@@ -61,12 +61,10 @@
   }
 }
 
-let appContext: (AppContext & Finalizable) | undefined;
-
 // Setup mocks for appContext.
 // This is a temporary solution
 // TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
+export function createDiffAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
     flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
     authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
@@ -77,14 +75,14 @@
     restApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('restApiService is not implemented');
     },
-    changeService: (_ctx: Partial<AppContext>) => {
-      throw new Error('changeService is not implemented');
+    changeModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('changeModel is not implemented');
     },
-    commentsService: (_ctx: Partial<AppContext>) => {
-      throw new Error('commentsService is not implemented');
+    commentsModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('commentsModel is not implemented');
     },
-    checksService: (_ctx: Partial<AppContext>) => {
-      throw new Error('checksService 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');
@@ -105,6 +103,5 @@
       throw new Error('browserModel is not implemented');
     },
   };
-  appContext = create<AppContext>(appRegistry);
-  injectAppContext(appContext);
+  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 7f964f4..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,18 +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';
+import {createDiffAppContext} from './gr-diff-app-context-init.js';
 
 suite('gr diff app context initializer tests', () => {
-  setup(() => {
-    initDiffAppContext();
-  });
-
   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 bfc56b4..46d2178 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -20,13 +20,13 @@
 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 {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {assertIsDefined} from '../utils/common-util';
@@ -51,19 +51,27 @@
     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>) => {
+    changeModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+      return new ChangeModel(ctx.restApiService!);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
+    commentsModel: (ctx: Partial<AppContext>) => {
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      const reporting = ctx.reportingService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      assertIsDefined(reporting, 'reportingService');
+      return new CommentsModel(changeModel, restApiService, reporting);
     },
-    checksService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksService(ctx.reportingService!);
+    checksModel: (ctx: Partial<AppContext>) => {
+      const changeModel = ctx.changeModel;
+      const reporting = ctx.reportingService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(reporting, 'reportingService');
+      return new ChecksModel(changeModel, reporting);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
@@ -71,8 +79,11 @@
     },
     storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
     configModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigModel(ctx.restApiService!);
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ConfigModel(changeModel, restApiService);
     },
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 53064fa..bea371f 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,12 +20,12 @@
 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 {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {ConfigModel} from './config/config-model';
@@ -36,9 +36,9 @@
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
-  commentsService: CommentsService;
-  checksService: ChecksService;
+  changeModel: ChangeModel;
+  commentsModel: CommentsModel;
+  checksModel: ChecksModel;
   jsApiService: JsApiService;
   storageService: StorageService;
   configModel: ConfigModel;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 458f610..6a819f8 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -15,13 +15,22 @@
  * 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 {
@@ -30,6 +39,12 @@
 } from '../../utils/patch-set-util';
 import {ParsedChangeInfo} from '../../types/types';
 
+import {routerChangeNum$} from '../router/router-model';
+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',
@@ -58,133 +73,189 @@
   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,
-        loadingStatus: LoadingStatus.NOT_LOADED,
-      });
-    }
-  }
-  privateState$.next({
-    ...current,
-    change,
-    loadingStatus:
-      change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
-  });
-}
-
-export function updateStateLoading() {
-  const current = privateState$.getValue();
-  privateState$.next({
-    ...current,
-    loadingStatus:
-      current.change === undefined
-        ? LoadingStatus.LOADING
-        : LoadingStatus.RELOADING,
-  });
-}
-
-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 changeLoadingStatus$ = changeState$.pipe(
-  map(changeState => changeState.loadingStatus),
-  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([routerState$, this.changeState$])
+      .pipe(
+        filter(([routerState, changeState]) => {
+          const changeNum = changeState.change?._number;
+          const routerChangeNum = routerState.changeNum;
+          return changeNum === undefined || changeNum === routerChangeNum;
+        }),
+        distinctUntilChanged()
+      )
+      .pipe(
+        withLatestFrom(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 restApiService: RestApiService) {
+    this.subscriptions = [
+      combineLatest([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..099a11f
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @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,
+  _testOnly_setState as setRouterState,
+} 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().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);
+
+    setRouterState({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));
+    setRouterState({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));
+    setRouterState({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>();
+    setRouterState({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));
+    setRouterState({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);
+    setRouterState({view: GerritView.DASHBOARD, 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);
+    setRouterState({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 c1b8c9b..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,126 +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 {combineLatest, from, fromEvent, Observable, Subscription} from 'rxjs';
-import {map, startWith, switchMap} from 'rxjs/operators';
-import {routerChangeNum$} from '../router/router-model';
-import {
-  change$,
-  updateStateChange,
-  updateStateLoading,
-  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[] = [];
-
-  // 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 restApiService: RestApiService) {
-    this.subscriptions.push(
-      combineLatest([routerChangeNum$, this.reload$])
-        .pipe(
-          map(([changeNum, _]) => changeNum),
-          switchMap(changeNum => {
-            if (changeNum !== undefined) updateStateLoading();
-            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.
-          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 f8aeb46..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,170 +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 {mockPromise, 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 {ChangeState, changeState$, LoadingStatus} from './change-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('change not loaded, loading, reloading, ...', async () => {
-    let promise = mockPromise<ParsedChangeInfo | undefined>();
-    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
-    let state: ChangeState | undefined = {
-      loadingStatus: LoadingStatus.NOT_LOADED,
-    };
-    changeState$.subscribe(s => (state = s));
-
-    await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
-    assert.equal(stub.callCount, 0);
-    assert.isUndefined(state?.change);
-
-    setRouterState({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);
-
-    promise = mockPromise<ParsedChangeInfo | undefined>();
-    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);
-
-    promise = mockPromise<ParsedChangeInfo | undefined>();
-    promise.resolve(undefined);
-    setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
-    await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
-    assert.equal(stub.callCount, 3);
-    assert.isUndefined(state?.change);
-
-    promise = mockPromise<ParsedChangeInfo | undefined>();
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
-    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
-    assert.equal(stub.callCount, 4);
-    assert.isUndefined(state?.change);
-
-    promise.resolve(knownChange);
-    await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
-    assert.equal(stub.callCount, 4);
-    assert.equal(state?.change, knownChange);
-  });
-
-  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..4024747 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 {routerPatchNum$} from '../router/router-model';
+import {Execution} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
 
 /**
  * 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,440 @@
         []
       )
       .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 changeModel: ChangeModel,
+    readonly reporting: ReportingService
+  ) {
+    this.subscriptions = [
+      this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+      this.checkToPluginMap$.subscribe(map => {
+        this.checkToPluginMap = map;
+      }),
+      combineLatest([
+        routerPatchNum$,
+        this.changeModel.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);
   }
-  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..0d46289 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,26 @@
   },
 ];
 
-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().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 +69,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..d46e08a 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 {routerChangeNum$} 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,282 @@
         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 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(
+      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..f39cad8 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -19,17 +19,28 @@
 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,
+  updateState as updateRouterState,
+} 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 +49,9 @@
       portedComments: {},
       portedDrafts: {},
       discardedDrafts: [],
-    });
-    updateStateDeleteDraft(draft);
-    assert.deepEqual(_testOnly_getState(), {
+    };
+    const output = deleteDraft(state, draft);
+    assert.deepEqual(output, {
       comments: {},
       robotComments: {},
       drafts: {
@@ -52,3 +63,65 @@
     });
   });
 });
+
+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().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 ?? {}))
+    );
+
+    updateRouterState(GerritView.CHANGE, 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 c0e6028..91a77b8 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -19,7 +19,7 @@
 import {switchMap} from 'rxjs/operators';
 import {Finalizable} from '../registry';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {repo$} from '../change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {select} from '../../utils/observable-util';
 
 export interface ConfigState {
@@ -43,6 +43,11 @@
     configState => configState.repoConfig
   );
 
+  public repoCommentLinks$ = select(
+    this.repoConfig$,
+    repoConfig => repoConfig?.commentlinks ?? {}
+  );
+
   public serverConfig$ = select(
     this.privateState$,
     configState => configState.serverConfig
@@ -50,12 +55,15 @@
 
   private subscriptions: Subscription[];
 
-  constructor(readonly restApiService: RestApiService) {
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService
+  ) {
     this.subscriptions = [
       from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
         this.updateServerConfig(config);
       }),
-      repo$
+      this.changeModel.repo$
         .pipe(
           switchMap((repo?: RepoName) => {
             if (repo === undefined) return of(undefined);
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 ec0cc57..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;
 
@@ -450,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>;
 
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 8f14077..e61ab15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -119,7 +119,7 @@
     );
     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);
@@ -159,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;
@@ -169,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;
@@ -195,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 274cb87..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,27 +21,10 @@
   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';
 import {getAppContext} from '../app-context';
 
-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;
-}
-
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
 
@@ -52,55 +35,6 @@
     );
   });
 
-  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'
-      );
-    });
-  });
-
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
     assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 0b6ef6b..9107b31d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,14 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
 import {
   DiffPreferencesInfo as DiffPreferencesInfoAPI,
   DiffViewMode,
 } from '../../api/diff';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  PreferencesInfo,
+} from '../../types/common';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
@@ -38,6 +41,7 @@
   account?: AccountDetailInfo;
   preferences: PreferencesInfo;
   diffPreferences: DiffPreferencesInfo;
+  capabilities?: AccountCapabilityInfo;
 }
 
 export class UserModel implements Finalizable {
@@ -58,6 +62,14 @@
     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
@@ -106,6 +118,16 @@
         .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);
+        }),
     ];
   }
 
@@ -154,6 +176,11 @@
     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});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index f041354..b8a5e49 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';
@@ -47,10 +45,6 @@
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
-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';
 
 declare global {
@@ -102,6 +96,7 @@
 
 window.fixture = fixtureImpl;
 let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
@@ -110,18 +105,15 @@
   // 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();
 
-  const shortcuts = getAppContext().shortcutsService;
+  const shortcuts = appContext.shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
@@ -217,7 +209,7 @@
   cancelAllTasks();
   cleanUpStorage();
   // Reset state
-  _testOnlyFinalizeAppContext();
+  appContext?.finalize();
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 8bcd395b..01d776e 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -18,25 +18,23 @@
 // 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 {UserModel} from '../services/user/user-model';
-import {CommentsService} from '../services/comments/comments-service';
+import {CommentsModel} from '../services/comments/comments-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> = {
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
@@ -47,17 +45,24 @@
       return new GrAuthMock(ctx.eventEmitter);
     },
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
-    changeService: (ctx: Partial<AppContext>) => {
+    changeModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+      return new ChangeModel(ctx.restApiService!);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
+    commentsModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
-    },
-    checksService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksService(ctx.reportingService!);
+      return new CommentsModel(
+        ctx.changeModel!,
+        ctx.restApiService!,
+        ctx.reportingService!
+      );
+    },
+    checksModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new ChecksModel(ctx.changeModel!, ctx.reportingService!);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
@@ -65,8 +70,9 @@
     },
     storageService: (_ctx: Partial<AppContext>) => grStorageMock,
     configModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigModel(ctx.restApiService!);
+      return new ConfigModel(ctx.changeModel!, ctx.restApiService!);
     },
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
@@ -82,11 +88,5 @@
       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 6e7c8f4..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];
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index f2da972..ebeb1db 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,7 +23,7 @@
 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 {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';
@@ -112,8 +112,8 @@
   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 UserModel>(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/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 c2991bf..b96ebe6 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -413,6 +413,12 @@
   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,
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();
+  });
+});