Merge "Clean up disrespectful terms"
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 3bb88e5..03ecd91 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -40,15 +40,32 @@
 
   private final DynamicItem<OAuthTokenEncrypter> encrypter;
 
+  public enum AccountIdSerializer implements CacheSerializer<Account.Id> {
+    INSTANCE;
+
+    private final Converter<Account.Id, Integer> converter =
+        Converter.from(Account.Id::get, Account::id);
+
+    private final Converter<Integer, Account.Id> reverse = converter.reverse();
+
+    @Override
+    public byte[] serialize(Account.Id object) {
+      return IntegerCacheSerializer.INSTANCE.serialize(converter.convert(object));
+    }
+
+    @Override
+    public Account.Id deserialize(byte[] in) {
+      return reverse.convert(IntegerCacheSerializer.INSTANCE.deserialize(in));
+    }
+  }
+
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
             .version(1)
-            .keySerializer(
-                CacheSerializer.convert(
-                    IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
+            .keySerializer(AccountIdSerializer.INSTANCE)
             .valueSerializer(new Serializer());
       }
     };
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 586f1bc..64fa74f 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
@@ -71,6 +72,23 @@
     assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
   }
 
+  @Test
+  public void serializeAndDeserializeBackAccountId() {
+    OAuthTokenCache.AccountIdSerializer serializer = OAuthTokenCache.AccountIdSerializer.INSTANCE;
+
+    Account.Id id = Account.id(1234);
+    assertThat(serializer.deserialize(serializer.serialize(id))).isEqualTo(id);
+  }
+
+  // Anonymous classes can break some cache implementations that try to parse the
+  // serializer class name and expect a well-defined class name: test that
+  // OAuthTokenCache.AccountIdSerializer is not an anonymous class.
+  @Test
+  public void accountIdSerializerIsNotAnAnonymousClass() {
+    assertThat(OAuthTokenCache.AccountIdSerializer.INSTANCE.getDeclaringClass().getSimpleName())
+        .isNotEmpty();
+  }
+
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void oAuthTokenFields() throws Exception {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
deleted file mode 100644
index 4989365..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ /dev/null
@@ -1,309 +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 '@polymer/iron-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-detail-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {encodeURL} from '../../../utils/url-util.js';
-
-const DETAIL_TYPES = {
-  BRANCHES: 'branches',
-  TAGS: 'tags',
-};
-
-const PGP_START = '-----BEGIN PGP SIGNATURE-----';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrRepoDetailList extends ListViewMixin(GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-detail-list'; }
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /**
-       * The kind of detail we are displaying, possibilities are determined by
-       * the const DETAIL_TYPES.
-       */
-      detailType: String,
-
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _repo: Object,
-      _items: Array,
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       */
-      _shownItems: {
-        type: Array,
-        computed: 'computeShownItems(_items)',
-      },
-      _itemsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-      _refName: String,
-      _hasNewItemName: Boolean,
-      _isEditing: Boolean,
-      _revisedRef: String,
-    };
-  }
-
-  _determineIfOwner(repo) {
-    return this.$.restAPI.getRepoAccess(repo)
-        .then(access =>
-          this._isOwner = access && !!access[repo].is_owner);
-  }
-
-  _paramsChanged(params) {
-    if (!params || !params.repo) { return; }
-
-    this._repo = params.repo;
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this._determineIfOwner(this._repo);
-      }
-    });
-
-    this.detailType = params.detail;
-
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
-
-    return this._getItems(this._filter, this._repo,
-        this._itemsPerPage, this._offset, this.detailType);
-  }
-
-  _getItems(filter, repo, itemsPerPage, offset, detailType) {
-    this._loading = true;
-    this._items = [];
-    flush();
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return this.$.restAPI.getRepoBranches(
-          filter, repo, itemsPerPage, offset, errFn).then(items => {
-        if (!items) { return; }
-        this._items = items;
-        this._loading = false;
-      });
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return this.$.restAPI.getRepoTags(
-          filter, repo, itemsPerPage, offset, errFn).then(items => {
-        if (!items) { return; }
-        this._items = items;
-        this._loading = false;
-      });
-    }
-  }
-
-  _getPath(repo) {
-    return `/admin/repos/${encodeURL(repo, false)},` +
-        `${this.detailType}`;
-  }
-
-  _computeWeblink(repo) {
-    if (!repo.web_links) { return ''; }
-    const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
-  }
-
-  _computeMessage(message) {
-    if (!message) { return; }
-    // Strip PGP info.
-    return message.split(PGP_START)[0];
-  }
-
-  _stripRefs(item, detailType) {
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return item.replace('refs/heads/', '');
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return item.replace('refs/tags/', '');
-    }
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _computeEditingClass(isEditing) {
-    return isEditing ? 'editing' : '';
-  }
-
-  _computeCanEditClass(ref, detailType, isOwner) {
-    return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
-      'canEdit' : '';
-  }
-
-  _handleEditRevision(e) {
-    this._revisedRef = e.model.get('item.revision');
-    this._isEditing = true;
-  }
-
-  _handleCancelRevision() {
-    this._isEditing = false;
-  }
-
-  _handleSaveRevision(e) {
-    this._setRepoHead(this._repo, this._revisedRef, e);
-  }
-
-  _setRepoHead(repo, ref, e) {
-    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
-      if (res.status < 400) {
-        this._isEditing = false;
-        e.model.set('item.revision', ref);
-        // This is needed to refresh _items property with fresh data,
-        // specifically can_delete from the json response.
-        this._getItems(
-            this._filter, this._repo, this._itemsPerPage,
-            this._offset, this.detailType);
-      }
-    });
-  }
-
-  _computeItemName(detailType) {
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return 'Branch';
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return 'Tag';
-    }
-  }
-
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    if (this.detailType === DETAIL_TYPES.BRANCHES) {
-      return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204) {
-              this._getItems(
-                  this._filter, this._repo, this._itemsPerPage,
-                  this._offset, this.detailType);
-            }
-          });
-    } else if (this.detailType === DETAIL_TYPES.TAGS) {
-      return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204) {
-              this._getItems(
-                  this._filter, this._repo, this._itemsPerPage,
-                  this._offset, this.detailType);
-            }
-          });
-    }
-  }
-
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteItem(e) {
-    const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-    if (!name) { return; }
-    this._refName = name;
-    this.$.overlay.open();
-  }
-
-  _computeHideDeleteClass(owner, canDelete) {
-    if (canDelete || owner) {
-      return 'show';
-    }
-
-    return '';
-  }
-
-  _handleCreateItem() {
-    this.$.createNewModal.handleCreateItem();
-    this._handleCloseCreate();
-  }
-
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
-  }
-
-  _handleCreateClicked() {
-    this.$.createOverlay.open();
-  }
-
-  _hideIfBranch(type) {
-    if (type === DETAIL_TYPES.BRANCHES) {
-      return 'hideItem';
-    }
-
-    return '';
-  }
-
-  _computeHideTagger(tagger) {
-    return tagger ? '' : 'hide';
-  }
-}
-
-customElements.define(GrRepoDetailList.is, GrRepoDetailList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
new file mode 100644
index 0000000..828d447
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -0,0 +1,381 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-detail-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {encodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import {
+  RepoName,
+  ProjectInfo,
+  BranchInfo,
+  GitRef,
+  TagInfo,
+  GitPersonInfo,
+} from '../../../types/common';
+import {AppElementRepoParams} from '../../gr-app-types';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+
+export interface GrRepoDetailList {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+    createOverlay: GrOverlay;
+    createNewModal: GrCreatePointerDialog;
+  };
+}
+@customElement('gr-repo-detail-list')
+export class GrRepoDetailList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementRepoParams;
+
+  @property({type: String})
+  detailType?: RepoDetailView;
+
+  @property({type: Boolean})
+  _editing = false;
+
+  @property({type: Boolean})
+  _isOwner = false;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: String})
+  _repo?: RepoName;
+
+  @property({type: Array})
+  _items?: BranchInfo[] | TagInfo[];
+
+  @property({type: Array, computed: 'computeShownItems(_items)'})
+  _shownItems?: BranchInfo[] | TagInfo[];
+
+  @property({type: Number})
+  _itemsPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter?: string;
+
+  @property({type: String})
+  _refName?: GitRef;
+
+  @property({type: Boolean})
+  _hasNewItemName?: boolean;
+
+  @property({type: Boolean})
+  _isEditing?: boolean;
+
+  @property({type: String})
+  _revisedRef?: GitRef;
+
+  _determineIfOwner(repo: RepoName) {
+    return this.$.restAPI
+      .getRepoAccess(repo)
+      .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+  }
+
+  _paramsChanged(params?: AppElementRepoParams) {
+    if (!params?.repo) {
+      return Promise.reject(new Error('undefined repo'));
+    }
+
+    this._repo = params.repo;
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn && this._repo) {
+        this._determineIfOwner(this._repo);
+      }
+    });
+
+    this.detailType = params.detail;
+
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+    if (!this.detailType)
+      return Promise.reject(new Error('undefined detailType'));
+
+    return this._getItems(
+      this._filter,
+      this._repo,
+      this._itemsPerPage,
+      this._offset,
+      this.detailType
+    );
+  }
+
+  // TODO(TS) Move this to object for easier read, understand.
+  _getItems(
+    filter: string | undefined,
+    repo: RepoName | undefined,
+    itemsPerPage: number,
+    offset: number | undefined,
+    detailType: string
+  ) {
+    if (filter === undefined || !repo || offset === undefined) {
+      return Promise.reject(new Error('filter or repo or offset undefined'));
+    }
+    this._loading = true;
+    this._items = [];
+    flush();
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+    if (detailType === RepoDetailView.BRANCHES) {
+      return this.$.restAPI
+        .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    } else if (detailType === RepoDetailView.TAGS) {
+      return this.$.restAPI
+        .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _getPath(repo: RepoName) {
+    return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+  }
+
+  _computeWeblink(repo: ProjectInfo) {
+    if (!repo.web_links) {
+      return '';
+    }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+
+  _computeMessage(message?: string) {
+    if (!message) {
+      return;
+    }
+    // Strip PGP info.
+    return message.split(PGP_START)[0];
+  }
+
+  _stripRefs(item: GitRef, detailType?: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return item.replace('refs/heads/', '');
+    } else if (detailType === RepoDetailView.TAGS) {
+      return item.replace('refs/tags/', '');
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _computeEditingClass(isEditing: boolean) {
+    return isEditing ? 'editing' : '';
+  }
+
+  _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+    return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+      ? 'canEdit'
+      : '';
+  }
+
+  _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
+    this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+    this._isEditing = true;
+  }
+
+  _handleCancelRevision() {
+    this._isEditing = false;
+  }
+
+  _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+    if (this._revisedRef && this._repo)
+      this._setRepoHead(this._repo, this._revisedRef, e);
+  }
+
+  _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+      if (res.status < 400) {
+        this._isEditing = false;
+        e.model.set('item.revision', ref);
+        // This is needed to refresh _items property with fresh data,
+        // specifically can_delete from the json response.
+        this._getItems(
+          this._filter,
+          this._repo,
+          this._itemsPerPage,
+          this._offset,
+          this.detailType!
+        );
+      }
+    });
+  }
+
+  _computeItemName(detailType: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === RepoDetailView.TAGS) {
+      return 'Tag';
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    if (!this._repo || !this._refName) {
+      return Promise.reject(new Error('undefined repo or refName'));
+    }
+    if (this.detailType === RepoDetailView.BRANCHES) {
+      return this.$.restAPI
+        .deleteRepoBranches(this._repo, this._refName)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204) {
+            this._getItems(
+              this._filter,
+              this._repo,
+              this._itemsPerPage,
+              this._offset,
+              this.detailType!
+            );
+          }
+        });
+    } else if (this.detailType === RepoDetailView.TAGS) {
+      return this.$.restAPI
+        .deleteRepoTags(this._repo, this._refName)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204) {
+            this._getItems(
+              this._filter,
+              this._repo,
+              this._itemsPerPage,
+              this._offset,
+              this.detailType!
+            );
+          }
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+    const name = this._stripRefs(
+      e.model.get('item.ref'),
+      this.detailType
+    ) as GitRef;
+    if (!name) {
+      return;
+    }
+    this._refName = name;
+    this.$.overlay.open();
+  }
+
+  _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+    if (canDelete || owner) {
+      return 'show';
+    }
+
+    return '';
+  }
+
+  _handleCreateItem() {
+    this.$.createNewModal.handleCreateItem();
+    this._handleCloseCreate();
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _hideIfBranch(type: string) {
+    if (type === RepoDetailView.BRANCHES) {
+      return 'hideItem';
+    }
+
+    return '';
+  }
+
+  _computeHideTagger(tagger: GitPersonInfo) {
+    return tagger ? '' : 'hide';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-detail-list': GrRepoDetailList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7f6ba08..f86f613 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-detail-list.js';
+import 'lodash/lodash.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
deleted file mode 100644
index 2230c38..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ /dev/null
@@ -1,441 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '../../../styles/gr-change-list-styles.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list-item/gr-change-list-item.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeList extends ChangeTableMixin(
-    KeyboardShortcutMixin(GestureEventListeners(
-        LegacyElementMixin(PolymerElement)))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list'; }
-  /**
-   * Fired when next page key shortcut was pressed.
-   *
-   * @event next-page
-   */
-
-  /**
-   * Fired when previous page key shortcut was pressed.
-   *
-   * @event previous-page
-   */
-
-  static get properties() {
-    return {
-    /**
-     * The logged-in user's account, or an empty object if no user is logged
-     * in.
-     */
-      account: {
-        type: Object,
-        value: null,
-      },
-      /**
-       * An array of ChangeInfo objects to render.
-       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-       */
-      changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-      /**
-       * ChangeInfo objects grouped into arrays. The sections and changes
-       * properties should not be used together.
-       *
-       * @type {!Array<{
-       *   name: string,
-       *   query: string,
-       *   results: !Array<!Object>
-       * }>}
-       */
-      sections: {
-        type: Array,
-        value() { return []; },
-      },
-      labelNames: {
-        type: Array,
-        computed: '_computeLabelNames(sections)',
-      },
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      showNumber: Boolean, // No default value to prevent flickering.
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showReviewedState: {
-        type: Boolean,
-        value: false,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      changeTableColumns: Array,
-      visibleChangeTableColumns: Array,
-      preferences: Object,
-      _config: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_sectionsChanged(sections.*)',
-      '_computePreferences(account, preferences, _config)',
-    ];
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-      [Shortcut.NEXT_PAGE]: '_nextPage',
-      [Shortcut.PREV_PAGE]: '_prevPage',
-      [Shortcut.OPEN_CHANGE]: '_openChange',
-      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-    };
-  }
-
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.restAPI.getConfig().then(config => {
-      this._config = config;
-    });
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicHeaderEndpoints = getPluginEndpoints().
-              getDynamicEndpoints('change-list-header');
-        });
-  }
-
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7294
-   */
-  _scopedKeydownHandler(e) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this._openChange(e);
-    }
-  }
-
-  _lowerCase(column) {
-    return column.toLowerCase();
-  }
-
-  _computePreferences(account, preferences, config) {
-    // Polymer 2: check for undefined
-    if ([account, preferences, config].includes(undefined)) {
-      return;
-    }
-
-    this.changeTableColumns = this.columnNames;
-    this.showNumber = false;
-    this.visibleChangeTableColumns = this.getEnabledColumns(this.columnNames,
-        config, this.flagsService.enabledExperiments);
-
-    if (account) {
-      this.showNumber = !!(preferences &&
-          preferences.legacycid_in_change_table);
-      if (preferences.change_table &&
-          preferences.change_table.length > 0) {
-        const prefColumns = this.getVisibleColumns(preferences.change_table);
-        this.visibleChangeTableColumns = this.getEnabledColumns(prefColumns,
-            config, this.flagsService.enabledExperiments);
-      }
-    }
-  }
-
-  _computeColspan(changeTableColumns, labelNames) {
-    if (!changeTableColumns || !labelNames) return;
-    return changeTableColumns.length + labelNames.length +
-        NUMBER_FIXED_COLUMNS;
-  }
-
-  _computeLabelNames(sections) {
-    if (!sections) { return []; }
-    let labels = [];
-    const nonExistingLabel = function(item) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) { continue; }
-      for (const change of section.results) {
-        if (!change.labels) { continue; }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
-    }
-    return labels.sort();
-  }
-
-  _computeLabelShortcut(labelName) {
-    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-    }
-    return labelName.split('-')
-        .reduce((a, i) => {
-          if (!i) { return a; }
-          return a + i[0].toUpperCase();
-        }, '')
-        .slice(0, MAX_SHORTCUT_CHARS);
-  }
-
-  _changesChanged(changes) {
-    this.sections = changes ? [{results: changes}] : [];
-  }
-
-  _processQuery(query) {
-    let tokens = query.split(' ');
-    const invalidTokens = ['limit:', 'age:', '-age:'];
-    tokens = tokens.filter(token => !invalidTokens
-        .some(invalidToken => token.startsWith(invalidToken)));
-    return tokens.join(' ');
-  }
-
-  _sectionHref(query) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
-  }
-
-  /**
-   * Maps an index local to a particular section to the absolute index
-   * across all the changes on the page.
-   *
-   * @param {number} sectionIndex index of section
-   * @param {number} localIndex index of row within section
-   * @return {number} absolute index of row in the aggregate dashboard
-   */
-  _computeItemAbsoluteIndex(sectionIndex, localIndex) {
-    let idx = 0;
-    for (let i = 0; i < sectionIndex; i++) {
-      idx += this.sections[i].results.length;
-    }
-    return idx + localIndex;
-  }
-
-  _computeItemSelected(sectionIndex, index, selectedIndex) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-    return idx == selectedIndex;
-  }
-
-  _computeTabIndex(sectionIndex, index, selectedIndex) {
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
-      ? 0 : undefined;
-  }
-
-  _computeItemNeedsReview(account, change, showReviewedState, config) {
-    const isAttentionSetEnabled =
-        !!config && !!config.change && config.change.enable_attention_set;
-    return !isAttentionSetEnabled && showReviewedState && !change.reviewed &&
-        !change.work_in_progress &&
-        changeIsOpen(change) &&
-        (!account || account._account_id != change.owner._account_id);
-  }
-
-  _computeItemHighlight(account, change) {
-    // Do not show the assignee highlight if the change is not open.
-    if (!change ||!change.assignee ||
-        !account ||
-        CLOSED_STATUS.indexOf(change.status) !== -1) {
-      return false;
-    }
-    return account._account_id === change.assignee._account_id;
-  }
-
-  _nextChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.next();
-  }
-
-  _prevChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.previous();
-  }
-
-  _openChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
-  }
-
-  _nextPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('next-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _prevPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('previous-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _toggleChangeReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleReviewedForIndex(this.selectedIndex);
-  }
-
-  _toggleReviewedForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.toggleReviewed();
-  }
-
-  _refreshChangeList(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._reloadWindow();
-  }
-
-  _reloadWindow() {
-    window.location.reload();
-  }
-
-  _toggleChangeStar(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleStarForIndex(this.selectedIndex);
-  }
-
-  _toggleStarForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.shadowRoot
-        .querySelector('gr-change-star').toggleStar();
-  }
-
-  _changeForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index < changeEls.length && changeEls[index]) {
-      return changeEls[index].change;
-    }
-    return null;
-  }
-
-  _getListItems() {
-    return Array.from(
-        this.root.querySelectorAll('gr-change-list-item'));
-  }
-
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.$.cursor.stops = this._getListItems();
-      this.$.cursor.moveToStart();
-    });
-  }
-
-  _getSpecialEmptySlot(section) {
-    if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === 'Your Turn') return 'empty-your-turn';
-    return '';
-  }
-
-  _isEmpty(section) {
-    return !section.results.length;
-  }
-}
-
-customElements.define(GrChangeList.is, GrChangeList);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
new file mode 100644
index 0000000..a05ccf4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -0,0 +1,508 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../../styles/gr-change-list-styles';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list-item/gr-change-list-item';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list_html';
+import {appContext} from '../../../services/app-context';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  CustomKeyboardEvent,
+  Modifier,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  GerritNav,
+  DashboardSection,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  PreferencesInput,
+} from '../../../types/common';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+
+export interface ChangeListSection {
+  results: ChangeInfo[];
+}
+export interface GrChangeList {
+  $: {
+    restAPI: RestApiService & Element;
+    cursor: GrCursorManager;
+  };
+}
+@customElement('gr-change-list')
+export class GrChangeList extends ChangeTableMixin(
+  KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))
+  )
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when next page key shortcut was pressed.
+   *
+   * @event next-page
+   */
+
+  /**
+   * Fired when previous page key shortcut was pressed.
+   *
+   * @event previous-page
+   */
+
+  /**
+   * The logged-in user's account, or an empty object if no user is logged
+   * in.
+   */
+  @property({type: Object})
+  account: AccountInfo | undefined = undefined;
+
+  @property({type: Array, observer: '_changesChanged'})
+  changes?: ChangeInfo[];
+
+  /**
+   * ChangeInfo objects grouped into arrays. The sections and changes
+   * properties should not be used together.
+   */
+  @property({type: Array})
+  sections: ChangeListSection[] = [];
+
+  @property({type: Array, computed: '_computeLabelNames(sections)'})
+  labelNames?: string[];
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Number, notify: true})
+  selectedIndex?: number;
+
+  @property({type: Boolean})
+  showNumber?: boolean; // No default value to prevent flickering.
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showReviewedState = false;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Array})
+  changeTableColumns?: string[];
+
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  flagsService = appContext.flagsService;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [Shortcut.NEXT_PAGE]: '_nextPage',
+      [Shortcut.PREV_PAGE]: '_prevPage',
+      [Shortcut.OPEN_CHANGE]: '_openChange',
+      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-list-header'
+        );
+      });
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7294
+   */
+  _scopedKeydownHandler(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._openChange((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  _lowerCase(column: string) {
+    return column.toLowerCase();
+  }
+
+  @observe('account', 'preferences', '_config')
+  _computePreferences(
+    account?: AccountInfo,
+    preferences?: PreferencesInput,
+    config?: ServerInfo
+  ) {
+    if (!config) {
+      return;
+    }
+
+    this.changeTableColumns = this.columnNames;
+    this.showNumber = false;
+    this.visibleChangeTableColumns = this.getEnabledColumns(
+      this.columnNames,
+      config,
+      this.flagsService.enabledExperiments
+    );
+
+    if (account && preferences) {
+      this.showNumber = !!(
+        preferences && preferences.legacycid_in_change_table
+      );
+      if (preferences.change_table && preferences.change_table.length > 0) {
+        const prefColumns = this.getVisibleColumns(preferences.change_table);
+        this.visibleChangeTableColumns = this.getEnabledColumns(
+          prefColumns,
+          config,
+          this.flagsService.enabledExperiments
+        );
+      }
+    }
+  }
+
+  _computeColspan(changeTableColumns: string[], labelNames: string[]) {
+    if (!changeTableColumns || !labelNames) return;
+    return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+  }
+
+  _computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) {
+      return [];
+    }
+    let labels: string[] = [];
+    const nonExistingLabel = function (item: string) {
+      return !labels.includes(item);
+    };
+    for (const section of sections) {
+      if (!section.results) {
+        continue;
+      }
+      for (const change of section.results) {
+        if (!change.labels) {
+          continue;
+        }
+        const currentLabels = Object.keys(change.labels);
+        labels = labels.concat(currentLabels.filter(nonExistingLabel));
+      }
+    }
+    return labels.sort();
+  }
+
+  _computeLabelShortcut(labelName: string) {
+    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+    }
+    return labelName
+      .split('-')
+      .reduce((a, i) => {
+        if (!i) {
+          return a;
+        }
+        return a + i[0].toUpperCase();
+      }, '')
+      .slice(0, MAX_SHORTCUT_CHARS);
+  }
+
+  _changesChanged(changes: ChangeInfo[]) {
+    this.sections = changes ? [{results: changes}] : [];
+  }
+
+  _processQuery(query: string) {
+    let tokens = query.split(' ');
+    const invalidTokens = ['limit:', 'age:', '-age:'];
+    tokens = tokens.filter(
+      token =>
+        !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
+    );
+    return tokens.join(' ');
+  }
+
+  _sectionHref(query: string) {
+    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  }
+
+  /**
+   * Maps an index local to a particular section to the absolute index
+   * across all the changes on the page.
+   *
+   * @param sectionIndex index of section
+   * @param localIndex index of row within section
+   * @return absolute index of row in the aggregate dashboard
+   */
+  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+    let idx = 0;
+    for (let i = 0; i < sectionIndex; i++) {
+      idx += this.sections[i].results.length;
+    }
+    return idx + localIndex;
+  }
+
+  _computeItemSelected(
+    sectionIndex: number,
+    index: number,
+    selectedIndex: number
+  ) {
+    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    return idx === selectedIndex;
+  }
+
+  _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0
+      : undefined;
+  }
+
+  _computeItemNeedsReview(
+    account: AccountInfo | undefined,
+    change: ChangeInfo,
+    showReviewedState: boolean,
+    config?: ServerInfo
+  ) {
+    const isAttentionSetEnabled =
+      !!config && !!config.change && config.change.enable_attention_set;
+    return (
+      !isAttentionSetEnabled &&
+      showReviewedState &&
+      !change.reviewed &&
+      !change.work_in_progress &&
+      changeIsOpen(change) &&
+      (!account || account._account_id !== change.owner._account_id)
+    );
+  }
+
+  _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
+    // Do not show the assignee highlight if the change is not open.
+    if (
+      !change ||
+      !change.assignee ||
+      !account ||
+      CLOSED_STATUS.indexOf(change.status) !== -1
+    ) {
+      return false;
+    }
+    return account._account_id === change.assignee._account_id;
+  }
+
+  _nextChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.next();
+  }
+
+  _prevChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.previous();
+  }
+
+  _openChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    const change = this._changeForIndex(this.selectedIndex);
+    if (change) GerritNav.navigateToChange(change);
+  }
+
+  _nextPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('next-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _prevPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('previous-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _toggleChangeReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleReviewedForIndex(this.selectedIndex);
+  }
+
+  _toggleReviewedForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    changeEl.toggleReviewed();
+  }
+
+  _refreshChangeList(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._reloadWindow();
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _toggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleStarForIndex(this.selectedIndex);
+  }
+
+  _toggleStarForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
+    if (grChangeStar) grChangeStar.toggleStar();
+  }
+
+  _changeForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index !== undefined && index < changeEls.length && changeEls[index]) {
+      return changeEls[index].change;
+    }
+    return null;
+  }
+
+  _getListItems() {
+    const items = this.root?.querySelectorAll('gr-change-list-item');
+    return !items ? [] : Array.from(items);
+  }
+
+  @observe('sections.*')
+  _sectionsChanged() {
+    // Flush DOM operations so that the list item elements will be loaded.
+    afterNextRender(this, () => {
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    });
+  }
+
+  _getSpecialEmptySlot(section: DashboardSection) {
+    if (section.isOutgoing) return 'empty-outgoing';
+    if (section.name === 'Your Turn') return 'empty-your-turn';
+    return '';
+  }
+
+  _isEmpty(section: DashboardSection) {
+    return !section.results?.length;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list': GrChangeList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 0493966..56a16b5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -50,8 +50,8 @@
   suite('test show change number not logged in', () => {
     setup(() => {
       element = basicFixture.instantiate();
-      element.account = null;
-      element.preferences = null;
+      element.account = undefined;
+      element.preferences = undefined;
       element._config = {};
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 179caaa..0fa8e31 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -789,6 +789,9 @@
 
   suite('_recomputeComments', () => {
     setup(() => {
+      element._changeNum = '1';
+      element._change = {_number: '1'};
+      flushAsynchronousOperations();
       // Fake computeDraftCount as its required for ChangeComments,
       // see gr-comment-api#reloadDrafts.
       sinon.stub(element.$.commentAPI, 'reloadDrafts')
@@ -927,7 +930,9 @@
           rev4: {_number: 4, commit: {parents: []}},
         },
         current_revision: 'rev4',
+        _number: '1',
       };
+      element._changeNum = '1';
       element._commentThreads = THREADS;
       const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
       MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[3]);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 358ebb0..c70678f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -1508,7 +1508,9 @@
         .querySelector('gr-button.send'));
     assert.isFalse(sendStub.called);
 
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    element.draftCommentThreads = [{comments: [
+      {__draft: true, path: 'test', line: 1, patch_set: 1},
+    ]}];
     flushAsynchronousOperations();
 
     MockInteractions.tap(element.shadowRoot
@@ -1521,7 +1523,9 @@
     // computed to false.
     element.draftCommentThreads = [];
     assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    element.draftCommentThreads = [{comments: [
+      {__draft: true, path: 'test', line: 1, patch_set: 1},
+    ]}];
     assert.equal(element.getFocusStops().end, element.$.sendButton);
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 5016c40..19e733e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -133,6 +133,7 @@
   hideIfEmpty?: boolean;
   assigneeOnly?: boolean;
   isOutgoing?: boolean;
+  results?: ChangeInfo[];
 }
 
 export interface UserDashboardConfig {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
deleted file mode 100644
index 2828cf1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ /dev/null
@@ -1,904 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../gr-button/gr-button.js';
-import '../gr-dialog/gr-dialog.js';
-import '../gr-date-formatter/gr-date-formatter.js';
-import '../gr-formatted-text/gr-formatted-text.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-overlay/gr-overlay.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-storage/gr-storage.js';
-import '../gr-textarea/gr-textarea.js';
-import '../gr-tooltip-content/gr-tooltip-content.js';
-import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
-import '../gr-account-label/gr-account-label.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-comment_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {getRootElement} from '../../../scripts/rootElement.js';
-import {appContext} from '../../../services/app-context.js';
-
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
-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';
-
-export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
-
-/**
- * All candidates tips to show, will pick randomly.
- */
-const RESPECTFUL_REVIEW_TIPS= [
-  'Assume competence.',
-  'Provide rationale or context.',
-  'Consider how comments may be interpreted.',
-  'Avoid harsh language.',
-  'Make your comments specific and actionable.',
-  'When disagreeing, explain the advantage of your approach.',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-comment'; }
-  /**
-   * Fired when the create fix comment action is triggered.
-   *
-   * @event create-fix-comment
-   */
-
-  /**
-   * Fired when the show fix preview action is triggered.
-   *
-   * @event open-fix-preview
-   */
-
-  /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * 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
-   */
-
-  /**
-   * Fired when the comment's timestamp is tapped.
-   *
-   * @event comment-anchor-tap
-   */
-
-  static get properties() {
-    return {
-      changeNum: String,
-      /** @type {!Gerrit.Comment} */
-      comment: {
-        type: Object,
-        notify: true,
-        observer: '_commentChanged',
-      },
-      comments: {
-        type: Array,
-      },
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: Boolean,
-        value: false,
-        observer: '_draftChanged',
-      },
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_editingChanged',
-      },
-      discarding: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      hasChildren: Boolean,
-      patchNum: String,
-      showActions: Boolean,
-      _showHumanActions: Boolean,
-      _showRobotActions: Boolean,
-      collapsed: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-        observer: '_toggleCollapseClass',
-      },
-      /** @type {?} */
-      projectConfig: Object,
-      robotButtonDisabled: Boolean,
-      _hasHumanReply: Boolean,
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
-      side: String,
-
-      resolved: Boolean,
-
-      _numPendingDraftRequests: {
-        type: Object,
-        value:
-          {number: 0}, // Intentional to share the object across instances.
-      },
-
-      _enableOverlay: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * 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.
-       */
-      _overlays: {
-        type: Object,
-        value: () => { return {}; },
-      },
-
-      _showRespectfulTip: {
-        type: Boolean,
-        value: false,
-      },
-      showPatchset: {
-        type: Boolean,
-        value: true,
-      },
-      _respectfulReviewTip: String,
-      _respectfulTipDismissed: {
-        type: Boolean,
-        value: false,
-      },
-      _unableToSave: {
-        type: Boolean,
-        value: false,
-      },
-      _selfAccount: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_commentMessageChanged(comment.message)',
-      '_loadLocalDraft(changeNum, patchNum, comment)',
-      '_isRobotComment(comment)',
-      '_calculateActionstoShow(showActions, isRobotComment)',
-      '_computeHasHumanReply(comment, comments.*)',
-      '_onEditingChange(editing)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.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;
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.cancelDebouncer('fire-update');
-    if (this.textarea) {
-      this.textarea.closeDropdown();
-    }
-  }
-
-  _getAuthor(comment) {
-    return comment.author || this._selfAccount;
-  }
-
-  _onEditingChange(editing) {
-    this.dispatchEvent(new CustomEvent('comment-editing-changed', {
-      detail: !!editing,
-      bubbles: true,
-      composed: true,
-    }));
-    if (!editing) return;
-    // 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];
-      this.reporting.reportInteraction(
-          'respectful-tip-appeared',
-          {tip: this._respectfulReviewTip}
-      );
-      // update cache
-      this.$.storage.setRespectfulTipVisibility();
-    }
-  }
-
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min, max) {
-    return Math.floor(Math.random() * (max - min) + min);
-  }
-
-  _computeVisibilityOfTip(showTip, tipDismissed) {
-    return showTip && !tipDismissed;
-  }
-
-  _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);
-  }
-
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
-  }
-
-  get textarea() {
-    return this.shadowRoot.querySelector('#editTextarea');
-  }
-
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot
-          .querySelector('#confirmDeleteOverlay');
-    }
-    return this._overlays.confirmDelete;
-  }
-
-  get confirmDiscardOverlay() {
-    if (!this._overlays.confirmDiscard) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDiscard = this.shadowRoot
-          .querySelector('#confirmDiscardOverlay');
-    }
-    return this._overlays.confirmDiscard;
-  }
-
-  _computeShowHideIcon(collapsed) {
-    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-  }
-
-  _computeShowHideAriaLabel(collapsed) {
-    return collapsed ? 'Expand' : 'Collapse';
-  }
-
-  _calculateActionstoShow(showActions, isRobotComment) {
-    // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].includes(undefined)) {
-      return;
-    }
-
-    this._showHumanActions = showActions && !isRobotComment;
-    this._showRobotActions = showActions && isRobotComment;
-  }
-
-  _isRobotComment(comment) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    return this.$.restAPI.getIsAdmin();
-  }
-
-  _computeDraftTooltip(unableToSave) {
-    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) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  /**
-   * @param {*=} opt_comment
-   */
-  save(opt_comment) {
-    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();
-    }
-
-    this._xhrPromise = this._saveDraft(comment).then(response => {
-      this.disabled = false;
-      if (!response.ok) { return response; }
-
-      this._eraseDraftComment();
-      return this.$.restAPI.getResponseObject(response).then(obj => {
-        const resComment = obj;
-        resComment.__draft = true;
-        // Maintain the ephemeral draft ID for identification by other
-        // elements.
-        if (this.comment.__draftID) {
-          resComment.__draftID = this.comment.__draftID;
-        }
-        resComment.__commentSide = this.commentSide;
-        this.comment = resComment;
-        this._fireSave();
-        return obj;
-      });
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-
-    return this._xhrPromise;
-  }
-
-  _eraseDraftComment() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.cancelDebouncer('store');
-
-    this.$.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
-    });
-  }
-
-  _commentChanged(comment) {
-    this.editing = !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    if (this.editing) { // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  _computeHasHumanReply() {
-    if (!this.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 === this.comment.id &&
-          !c.robot_id);
-  }
-
-  /**
-   * @param {!Object=} opt_mixin
-   *
-   * @return {!Object}
-   */
-  _getEventPayload(opt_mixin) {
-    return {...opt_mixin, comment: this.comment,
-      patchNum: this.patchNum};
-  }
-
-  _fireSave() {
-    this.dispatchEvent(new CustomEvent('comment-save', {
-      detail: this._getEventPayload(),
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _fireUpdate() {
-    this.debounce('fire-update', () => {
-      this.dispatchEvent(new CustomEvent('comment-update', {
-        detail: this._getEventPayload(),
-        composed: true, bubbles: true,
-      }));
-    });
-  }
-
-  _computeAccountLabelClass(draft) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing, previousValue) {
-    // 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');
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
-      }
-    }
-    if (this.comment) {
-      this.comment.__editing = this.editing;
-    }
-    if (editing != !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      this.async(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin, draft) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(draft, comment, resolved) {
-    // 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) {
-    if (!this._computeSaveDisabled(this._messageText, this.comment,
-        this.resolved)) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc(e) {
-    if (!this._messageText.length) {
-      e.preventDefault();
-      this._handleCancel(e);
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  _commentMessageChanged(message) {
-    this._messageText = message || '';
-  }
-
-  _messageTextChanged(newValue, oldValue) {
-    if (!this.comment || (this.comment && this.comment.id)) {
-      return;
-    }
-
-    this.debounce('store', () => {
-      const message = this._messageText;
-      const commentLocation = {
-        changeNum: this.changeNum,
-        patchNum: this._getPatchNum(),
-        path: this.comment.path,
-        line: this.comment.line,
-        range: this.comment.range,
-      };
-
-      if ((!this._messageText || !this._messageText.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) {
-    e.preventDefault();
-    if (!this.comment.line) {
-      return;
-    }
-    this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
-      bubbles: true,
-      composed: true,
-      detail: {
-        number: this.comment.line || FILE,
-        side: this.side,
-      },
-    }));
-  }
-
-  _handleEdit(e) {
-    e.preventDefault();
-    this._messageText = this.comment.message;
-    this.editing = true;
-    this.reporting.recordDraftInteraction();
-  }
-
-  _handleSave(e) {
-    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(); });
-  }
-
-  _handleCancel(e) {
-    e.preventDefault();
-
-    if (!this.comment.message ||
-        this.comment.message.trim().length === 0 ||
-        !this.comment.id) {
-      this._fireDiscard();
-      return;
-    }
-    this._messageText = this.comment.message;
-    this.editing = false;
-  }
-
-  _fireDiscard() {
-    this.cancelDebouncer('fire-update');
-    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) {
-    return !comment || !comment.fix_suggestions;
-  }
-
-  _handleDiscard(e) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    if (!this._messageText) {
-      this._discardDraft();
-      return;
-    }
-
-    this._openOverlay(this.confirmDiscardOverlay).then(() => {
-      this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
-          .resetFocus();
-    });
-  }
-
-  _handleConfirmDiscard(e) {
-    e.preventDefault();
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this._closeConfirmDiscardOverlay();
-    return this._discardDraft().then(() => { timer.end(); });
-  }
-
-  _discardDraft() {
-    if (!this.comment.__draft) {
-      throw Error('Cannot discard a non-draft comment.');
-    }
-    this.discarding = true;
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftComment();
-
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return;
-    }
-
-    this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-      this.disabled = false;
-      if (!response.ok) {
-        this.discarding = false;
-        return response;
-      }
-
-      this._fireDiscard();
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-
-    return this._xhrPromise;
-  }
-
-  _closeConfirmDiscardOverlay() {
-    this._closeOverlay(this.confirmDiscardOverlay);
-  }
-
-  _getSavingMessage(numPending, requestFailed) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
-    }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return [
-      SAVING_MESSAGE,
-      numPending,
-      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-    ].join(' ');
-  }
-
-  _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.cancelDebouncer('draft-toast');
-    this._updateRequestToast(this._numPendingDraftRequests.number,
-        /* requestFailed=*/true);
-  }
-
-  _updateRequestToast(numPending, requestFailed) {
-    const message = this._getSavingMessage(numPending, requestFailed);
-    this.debounce('draft-toast', () => {
-      // 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.
-      document.body.dispatchEvent(new CustomEvent(
-          'show-alert', {detail: {message}, bubbles: true, composed: true}));
-    }, TOAST_DEBOUNCE_INTERVAL);
-  }
-
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
-  }
-
-  _saveDraft(draft) {
-    this._showStartRequest();
-    return this.$.restAPI.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);
-        });
-  }
-
-  _deleteDraft(draft) {
-    this._showStartRequest();
-    return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-        draft).then(result => {
-      if (result.ok) {
-        this._showEndRequest();
-      } else {
-        this._handleFailedDraftRequest();
-      }
-      return result;
-    });
-  }
-
-  _getPatchNum() {
-    return this.isOnParent() ? 'PARENT' : this.patchNum;
-  }
-
-  _loadLocalDraft(changeNum, patchNum, comment) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that haven't been saved
-    // remotely, and haven't been given a default message already.
-    //
-    // Don't get local draft if there is another comment that is currently
-    // in an editing state.
-    if (!comment || comment.id || comment.message || comment.__otherEditing) {
-      delete comment.__otherEditing;
-      return;
-    }
-
-    const draft = this.$.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this.set('comment.message', 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();
-    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) {
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment, collapsed) {
-    if (!comment) return true;
-    return !(comment.robot_id && comment.url && !collapsed);
-  }
-
-  _closeOverlay(overlay) {
-    getRootElement().removeChild(overlay);
-    overlay.close();
-  }
-
-  _handleConfirmDeleteComment() {
-    const dialog =
-        this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-    this.$.restAPI.deleteComment(
-        this.changeNum, this.patchNum, this.comment.id, dialog.message)
-        .then(newComment => {
-          this._handleCancelDeleteComment();
-          this.comment = newComment;
-        });
-  }
-}
-
-customElements.define(GrComment.is, GrComment);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
new file mode 100644
index 0000000..209b2c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -0,0 +1,1050 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-button/gr-button';
+import '../gr-dialog/gr-dialog';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-formatted-text/gr-formatted-text';
+import '../gr-icons/gr-icons';
+import '../gr-overlay/gr-overlay';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-storage/gr-storage';
+import '../gr-textarea/gr-textarea';
+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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-comment_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {getRootElement} from '../../../scripts/rootElement';
+import {appContext} from '../../../services/app-context';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {GrOverlay} from '../gr-overlay/gr-overlay';
+import {
+  RobotCommentInfo,
+  PatchSetNum,
+  CommentInfo,
+  ConfigInfo,
+  AccountDetailInfo,
+} from '../../../types/common';
+import {GrButton} from '../gr-button/gr-button';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
+
+const STORAGE_DEBOUNCE_INTERVAL = 400;
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+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';
+
+export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
+
+/**
+ * All candidates tips to show, will pick randomly.
+ */
+const RESPECTFUL_REVIEW_TIPS = [
+  'Assume competence.',
+  'Provide rationale or context.',
+  'Consider how comments may be interpreted.',
+  'Avoid harsh language.',
+  'Make your comments specific and actionable.',
+  'When disagreeing, explain the advantage of your approach.',
+];
+
+interface Draft {
+  collapsed?: boolean;
+  __editing?: boolean;
+  __otherEditing?: boolean;
+  __draft?: boolean;
+  __draftID?: number;
+  __commentSide?: string;
+}
+
+export type Comment = Draft & CommentInfo;
+export type RobotComment = Draft & RobotCommentInfo;
+
+interface CommentOverlays {
+  confirmDelete?: GrOverlay | null;
+  confirmDiscard?: GrOverlay | null;
+}
+
+export interface GrComment {
+  $: {
+    restAPI: RestApiService & Element;
+    storage: GrStorage;
+    container: HTMLDivElement;
+    resolvedCheckbox: HTMLInputElement;
+  };
+}
+@customElement('gr-comment')
+export class GrComment extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the create fix comment action is triggered.
+   *
+   * @event create-fix-comment
+   */
+
+  /**
+   * Fired when the show fix preview action is triggered.
+   *
+   * @event open-fix-preview
+   */
+
+  /**
+   * Fired when this comment is discarded.
+   *
+   * @event comment-discard
+   */
+
+  /**
+   * 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
+   */
+
+  /**
+   * Fired when the comment's timestamp is tapped.
+   *
+   * @event comment-anchor-tap
+   */
+
+  @property({type: Number})
+  changeNum?: number;
+
+  @property({type: Object, notify: true, observer: '_commentChanged'})
+  comment?: Comment | RobotComment;
+
+  @property({type: Array})
+  comments?: (Comment | RobotComment)[];
+
+  @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;
+
+  @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?: boolean;
+
+  @property({type: Boolean})
+  _hasHumanReply?: boolean;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Object})
+  _xhrPromise?: Promise<any>; // Used for testing.
+
+  @property({type: String, observer: '_messageTextChanged'})
+  _messageText = '';
+
+  @property({type: String})
+  commentSide?: string;
+
+  @property({type: String})
+  side?: string;
+
+  @property({type: Boolean})
+  resolved?: boolean;
+
+  // Intentional to share the object across instances.
+  @property({type: Object})
+  _numPendingDraftRequests: {number: number} = {number: 0};
+
+  @property({type: Boolean})
+  _enableOverlay = false;
+
+  /**
+   * 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.
+   */
+  @property({type: Object})
+  _overlays: CommentOverlays = {};
+
+  @property({type: Boolean})
+  _showRespectfulTip = false;
+
+  @property({type: Boolean})
+  showPatchset = true;
+
+  @property({type: String})
+  _respectfulReviewTip?: string;
+
+  @property({type: Boolean})
+  _respectfulTipDismissed = false;
+
+  @property({type: Boolean})
+  _unableToSave = false;
+
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
+
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+      esc: '_handleEsc',
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.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;
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('fire-update');
+    if (this.textarea) {
+      this.textarea.closeDropdown();
+    }
+  }
+
+  _getAuthor(comment: Comment) {
+    return comment.author || this._selfAccount;
+  }
+
+  @observe('editing')
+  _onEditingChange(editing?: boolean) {
+    this.dispatchEvent(
+      new CustomEvent('comment-editing-changed', {
+        detail: !!editing,
+        bubbles: true,
+        composed: true,
+      })
+    );
+    if (!editing) return;
+    // 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];
+      this.reporting.reportInteraction('respectful-tip-appeared', {
+        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);
+  }
+
+  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
+    return showTip && !tipDismissed;
+  }
+
+  _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);
+  }
+
+  _onRespectfulReadMoreClick() {
+    this.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  get textarea(): GrTextarea | null {
+    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+  }
+
+  get confirmDeleteOverlay() {
+    if (!this._overlays.confirmDelete) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
+        '#confirmDeleteOverlay'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDelete;
+  }
+
+  get confirmDiscardOverlay() {
+    if (!this._overlays.confirmDiscard) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
+        '#confirmDiscardOverlay'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDiscard;
+  }
+
+  _computeShowHideIcon(collapsed: boolean) {
+    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+  }
+
+  _computeShowHideAriaLabel(collapsed: boolean) {
+    return collapsed ? 'Expand' : 'Collapse';
+  }
+
+  @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;
+  }
+
+  @observe('comment')
+  _isRobotComment(comment: RobotComment) {
+    this.isRobotComment = !!comment.robot_id;
+  }
+
+  isOnParent() {
+    return this.side === 'PARENT';
+  }
+
+  _getIsAdmin() {
+    return this.$.restAPI.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)' : '');
+  }
+
+  save(opt_comment?: Comment) {
+    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();
+    }
+
+    this._xhrPromise = this._saveDraft(comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          return;
+        }
+
+        this._eraseDraftComment();
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const resComment = (obj as unknown) as Comment;
+          resComment.__draft = true;
+          // Maintain the ephemeral draft ID for identification by other
+          // elements.
+          if (this.comment?.__draftID) {
+            resComment.__draftID = this.comment.__draftID;
+          }
+          resComment.__commentSide = this.commentSide;
+          this.comment = resComment;
+          this._fireSave();
+          return obj;
+        });
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+    return this._xhrPromise;
+  }
+
+  _eraseDraftComment() {
+    // Prevents a race condition in which removing the draft comment occurs
+    // prior to it being saved.
+    this.cancelDebouncer('store');
+
+    if (!this.comment?.path || this.comment.line === undefined)
+      throw new Error('Cannot erase Draft Comment');
+    if (this.changeNum === undefined) {
+      throw new Error('undefined changeNum');
+    }
+    this.$.storage.eraseDraftComment({
+      changeNum: this.changeNum,
+      patchNum: this._getPatchNum(),
+      path: this.comment.path,
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
+  _commentChanged(comment: Comment) {
+    this.editing = !!comment.__editing;
+    this.resolved = !comment.unresolved;
+    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 RobotComment).robot_id
+    );
+  }
+
+  _getEventPayload(opt_mixin?: Record<string, any>) {
+    return {...opt_mixin, comment: this.comment, patchNum: this.patchNum};
+  }
+
+  _fireSave() {
+    this.dispatchEvent(
+      new CustomEvent('comment-save', {
+        detail: this._getEventPayload(),
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _fireUpdate() {
+    this.debounce('fire-update', () => {
+      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;
+      }
+    }
+    if (this.comment) {
+      this.comment.__editing = this.editing;
+    }
+    if (!!editing !== !!previousValue) {
+      // To prevent event firing on comment creation.
+      this._fireUpdate();
+    }
+    if (editing) {
+      this.async(() => {
+        flush();
+        this.textarea && this.textarea.putCursorAtEnd();
+      }, 1);
+    }
+  }
+
+  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
+    return isAdmin && !draft ? 'showDeleteButtons' : '';
+  }
+
+  _computeSaveDisabled(
+    draft: string,
+    comment: Comment | undefined,
+    resolved?: boolean
+  ) {
+    // If resolved state has changed and a msg exists, save should be enabled.
+    if (!comment || (comment.unresolved === resolved && draft)) {
+      return false;
+    }
+    return !draft || draft.trim() === '';
+  }
+
+  _handleSaveKey(e: Event) {
+    if (
+      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
+    ) {
+      e.preventDefault();
+      this._handleSave(e);
+    }
+  }
+
+  _handleEsc(e: Event) {
+    if (!this._messageText.length) {
+      e.preventDefault();
+      this._handleCancel(e);
+    }
+  }
+
+  _handleToggleCollapsed() {
+    this.collapsed = !this.collapsed;
+  }
+
+  _toggleCollapseClass(collapsed: boolean) {
+    if (collapsed) {
+      this.$.container.classList.add('collapsed');
+    } else {
+      this.$.container.classList.remove('collapsed');
+    }
+  }
+
+  @observe('comment.message')
+  _commentMessageChanged(message: string) {
+    this._messageText = message || '';
+  }
+
+  _messageTextChanged(_: string, oldValue: string) {
+    if (!this.comment || (this.comment && this.comment.id)) {
+      return;
+    }
+
+    const patchNum = this.comment.patch_set
+      ? this.comment.patch_set
+      : this._getPatchNum();
+    this.debounce(
+      'store',
+      () => {
+        const message = this._messageText;
+        if (!this.comment?.path || this.comment.line === undefined)
+          throw new Error('missing path or line in comment');
+        if (this.changeNum === undefined) {
+          throw new Error('undefined changeNum');
+        }
+        const commentLocation: StorageLocation = {
+          changeNum: this.changeNum,
+          patchNum,
+          path: this.comment.path,
+          line: this.comment.line,
+          range: this.comment.range,
+        };
+
+        if ((!this._messageText || !this._messageText.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?.line) {
+      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) throw new Error('message undefined');
+    this._messageText = this.comment.message;
+    this.editing = true;
+    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();
+    });
+  }
+
+  _handleCancel(e: Event) {
+    e.preventDefault();
+
+    if (
+      !this.comment?.message ||
+      this.comment.message.trim().length === 0 ||
+      !this.comment.id
+    ) {
+      this._fireDiscard();
+      return;
+    }
+    this._messageText = this.comment.message;
+    this.editing = false;
+  }
+
+  _fireDiscard() {
+    this.cancelDebouncer('fire-update');
+    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: Comment) {
+    return !comment || !(comment as RobotComment).fix_suggestions;
+  }
+
+  _handleDiscard(e: Event) {
+    e.preventDefault();
+    this.reporting.recordDraftInteraction();
+
+    if (!this._messageText) {
+      this._discardDraft();
+      return;
+    }
+
+    this._openOverlay(this.confirmDiscardOverlay).then(() => {
+      const dialog = this.confirmDiscardOverlay?.querySelector(
+        '#confirmDiscardDialog'
+      ) as GrDialog | null;
+      if (dialog) dialog.resetFocus();
+    });
+  }
+
+  _handleConfirmDiscard(e: Event) {
+    e.preventDefault();
+    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
+    this._closeConfirmDiscardOverlay();
+    return this._discardDraft().then(() => {
+      timer.end();
+    });
+  }
+
+  _discardDraft() {
+    if (!this.comment) return Promise.reject(new Error('undefined comment'));
+    if (!this.comment.__draft) {
+      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
+    }
+    this.discarding = true;
+    this.editing = false;
+    this.disabled = true;
+    this._eraseDraftComment();
+
+    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;
+        }
+
+        this._fireDiscard();
+        return response;
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+    return this._xhrPromise;
+  }
+
+  _closeConfirmDiscardOverlay() {
+    this._closeOverlay(this.confirmDiscardOverlay);
+  }
+
+  _getSavingMessage(numPending: number, requestFailed?: boolean) {
+    if (requestFailed) {
+      return UNSAVED_MESSAGE;
+    }
+    if (numPending === 0) {
+      return SAVED_MESSAGE;
+    }
+    return [
+      SAVING_MESSAGE,
+      numPending,
+      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+    ].join(' ');
+  }
+
+  _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.cancelDebouncer('draft-toast');
+    this._updateRequestToast(
+      this._numPendingDraftRequests.number,
+      /* requestFailed=*/ true
+    );
+  }
+
+  _updateRequestToast(numPending: number, requestFailed?: boolean) {
+    const message = this._getSavingMessage(numPending, requestFailed);
+    this.debounce(
+      'draft-toast',
+      () => {
+        // 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.
+        document.body.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message},
+            bubbles: true,
+            composed: true,
+          })
+        );
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  _handleDraftFailure() {
+    this.$.container.classList.add('unableToSave');
+    this._unableToSave = true;
+    this._handleFailedDraftRequest();
+  }
+
+  _saveDraft(draft?: Comment) {
+    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined draft or changeNum or patchNum');
+    }
+    this._showStartRequest();
+    return this.$.restAPI
+      .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;
+      });
+  }
+
+  _deleteDraft(draft: Comment) {
+    if (this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined changeNum or patchNum');
+    }
+    this._showStartRequest();
+    return this.$.restAPI
+      .deleteDiffDraft(this.changeNum, this.patchNum, draft)
+      .then(result => {
+        if (result.ok) {
+          this._showEndRequest();
+        } else {
+          this._handleFailedDraftRequest();
+        }
+        return result;
+      });
+  }
+
+  _getPatchNum(): PatchSetNum {
+    const patchNum = this.isOnParent()
+      ? ('PARENT' as PatchSetNum)
+      : this.patchNum;
+    if (patchNum === undefined) throw new Error('patchNum undefined');
+    return patchNum;
+  }
+
+  @observe('changeNum', 'patchNum', 'comment')
+  _loadLocalDraft(
+    changeNum: number,
+    patchNum?: PatchSetNum,
+    comment?: Comment
+  ) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchNum, comment].includes(undefined)) {
+      return;
+    }
+
+    // Only apply local drafts to comments that haven't been saved
+    // remotely, and haven't been given a default message already.
+    //
+    // Don't get local draft if there is another comment that is currently
+    // in an editing state.
+    if (
+      !comment ||
+      comment.id ||
+      comment.message ||
+      comment.__otherEditing ||
+      !comment.path ||
+      !comment.line
+    ) {
+      if (comment) delete comment.__otherEditing;
+      return;
+    }
+
+    const draft = this.$.storage.getDraftComment({
+      changeNum,
+      patchNum: this._getPatchNum(),
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+    });
+
+    if (draft) {
+      this.set('comment.message', 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: RobotComment, collapsed: boolean) {
+    if (!comment) return true;
+    return !(comment.robot_id && comment.url && !collapsed);
+  }
+
+  _closeOverlay(overlay?: GrOverlay | null) {
+    if (overlay) {
+      getRootElement().removeChild(overlay);
+      overlay.close();
+    }
+  }
+
+  _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.changeNum === undefined ||
+      this.patchNum === undefined
+    ) {
+      throw new Error('undefined comment or changeNum or patchNum');
+    }
+    this.$.restAPI
+      .deleteComment(
+        this.changeNum,
+        this.patchNum,
+        this.comment.id,
+        dialog.message
+      )
+      .then(newComment => {
+        this._handleCancelDeleteComment();
+        this.comment = newComment;
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comment': GrComment;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 1299b90..a9a1d32 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -149,6 +149,7 @@
           email: 'tenn1sballchaser@aol.com',
         },
         line: 5,
+        path: 'test',
       };
       flush(() => {
         assert.isTrue(loadSpy.called);
@@ -368,11 +369,13 @@
 
     test('failed save draft request', done => {
       element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
         sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
             Promise.resolve({ok: false}));
-      element._saveDraft();
+      element._saveDraft({id: 'abc_123'});
       flush(() => {
         let args = updateRequestStub.lastCall.args;
         assert.deepEqual(args, [0, true]);
@@ -384,7 +387,7 @@
             .querySelector('.save')), 'save is visible');
         diffDraftStub.returns(
             Promise.resolve({ok: true}));
-        element._saveDraft();
+        element._saveDraft({id: 'abc_123'});
         flush(() => {
           args = updateRequestStub.lastCall.args;
           assert.deepEqual(args, [0]);
@@ -402,11 +405,13 @@
 
     test('failed save draft request with promise failure', done => {
       element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
         sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
             Promise.reject(new Error()));
-      element._saveDraft();
+      element._saveDraft({id: 'abc_123'});
       flush(() => {
         let args = updateRequestStub.lastCall.args;
         assert.deepEqual(args, [0, true]);
@@ -418,7 +423,7 @@
             .querySelector('.save')), 'save is visible');
         diffDraftStub.returns(
             Promise.resolve({ok: true}));
-        element._saveDraft();
+        element._saveDraft({id: 'abc_123'});
         flush(() => {
           args = updateRequestStub.lastCall.args;
           assert.deepEqual(args, [0]);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index c4ebe1e..a3233be 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -137,6 +137,11 @@
         this.listen(this, 'mouseleave', 'unlock');
       }
 
+      detached() {
+        super.detached();
+        this.unlock();
+      }
+
       /** @override */
       ready() {
         super.ready();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index f4111e7..8461027 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -125,6 +125,7 @@
   ProjectAccessInfo,
   CapabilityInfoMap,
   ProjectInfoWithName,
+  TagInfo,
 } from '../../../types/common';
 import {
   CancelConditionCallback,
@@ -1795,11 +1796,11 @@
       `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
-    return this._restApiHelper.fetchJSON({
+    return (this._restApiHelper.fetchJSON({
       url,
       errFn,
       anonymizedUrl: '/projects/*/tags',
-    });
+    }) as unknown) as Promise<TagInfo[]>;
   }
 
   getPlugins(
@@ -3226,7 +3227,7 @@
     commentID: UrlEncodedCommentId,
     reason: string
   ) {
-    return this._getChangeURLAndSend({
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       patchNum,
@@ -3234,7 +3235,7 @@
       body: {reason},
       parseResponse: true,
       anonymizedEndpoint: '/comments/*/delete',
-    });
+    }) as unknown) as Promise<CommentInfo>;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 176f6c9..15914c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -25,7 +25,7 @@
   patchNum: PatchSetNum;
   path: string;
   line: number;
-  range: CommentRange;
+  range?: CommentRange;
 }
 
 export interface StorageObject {
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index e8fc3f9..5565180 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -78,6 +78,9 @@
   GroupAuditEventInfo,
   EncodedGroupId,
   Base64FileContent,
+  UrlEncodedCommentId,
+  TagInfo,
+  GitRef,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -604,6 +607,18 @@
     label: string
   ): Promise<Response>;
 
+  deleteComment(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    commentID: UrlEncodedCommentId,
+    reason: string
+  ): Promise<CommentInfo>;
+  deleteDiffDraft(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    draft: {id: UrlEncodedCommentId}
+  ): Promise<Response>;
+
   deleteChangeCommitMessage(
     changeNum: ChangeNum,
     messageId: ChangeMessageId
@@ -662,4 +677,15 @@
     path: string,
     contents: string
   ): Promise<Response>;
+  getRepoTags(
+    filter: string,
+    repo: RepoName,
+    reposTagsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<TagInfo[]>;
+
+  setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index cc74ef1..ea7fb75 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1039,7 +1039,7 @@
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
-export interface CommentInfo {
+export interface CommentInfo extends CommentInput {
   patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
   path?: string;
@@ -1047,9 +1047,9 @@
   parent?: number;
   line?: number;
   range?: CommentRange;
-  in_reply_to?: string;
+  in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: string;
+  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
@@ -2008,3 +2008,18 @@
   title: string;
   url: string;
 }
+
+/**
+ * The TagInfo entity contains information about a tag.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info
+ **/
+export interface TagInfo {
+  ref: GitRef;
+  revision: string;
+  object?: string;
+  message?: string;
+  tagger?: GitPersonInfo;
+  created?: string;
+  can_delete: boolean;
+  web_links?: WebLinkInfo[];
+}