Merge "Update stale comment in ModuleOverloader"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index b311efc..f0b8372 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -753,6 +753,7 @@
     class="scrollable"
     no-cancel-on-outside-click=""
     no-cancel-on-esc-key=""
+    scroll-action="lock"
     with-backdrop=""
   >
     <gr-reply-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
deleted file mode 100644
index 0da687f..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ /dev/null
@@ -1,430 +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-icon/iron-icon.js';
-import '../../shared/gr-account-label/gr-account-label.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-formatted-text/gr-formatted-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-voting-styles.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-message_html.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-
-/**
- * @extends PolymerElement
- */
-class GrMessage extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-message'; }
-  /**
-   * Fired when this message's reply link is tapped.
-   *
-   * @event reply
-   */
-
-  /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
-   * Fired when a change message is deleted.
-   *
-   * @event change-message-deleted
-   */
-
-  static get properties() {
-    return {
-      /** @type {?} */
-      change: Object,
-      changeNum: Number,
-      /** @type {?} */
-      message: Object,
-      author: {
-        type: Object,
-        computed: '_computeAuthor(message)',
-      },
-      /**
-       * TODO(taoalpha): remove once the change log experiment is launched
-       *
-       * @type {Object} - a map on file and comments on it
-       */
-      comments: {
-        type: Object,
-      },
-      config: Object,
-      hideAutomated: {
-        type: Boolean,
-        value: false,
-      },
-      hidden: {
-        type: Boolean,
-        computed: '_computeIsHidden(hideAutomated, isAutomated)',
-        reflectToAttribute: true,
-      },
-      isAutomated: {
-        type: Boolean,
-        computed: '_computeIsAutomated(message)',
-      },
-      showOnBehalfOf: {
-        type: Boolean,
-        computed: '_computeShowOnBehalfOf(message)',
-      },
-      showReplyButton: {
-        type: Boolean,
-        computed: '_computeShowReplyButton(message, _loggedIn)',
-      },
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-
-      /**
-       * A mapping from label names to objects representing the minimum and
-       * maximum possible values for that label.
-       */
-      labelExtremes: Object,
-
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      _projectConfig: Object,
-      // Computed property needed to trigger Polymer value observing.
-      _expanded: {
-        type: Object,
-        computed: '_computeExpanded(message.expanded)',
-      },
-      _messageContentExpanded: {
-        type: String,
-        computed:
-            '_computeMessageContentExpanded(message.message, message.tag)',
-      },
-      _messageContentCollapsed: {
-        type: String,
-        computed:
-            '_computeMessageContentCollapsed(message.message, message.tag,' +
-            ' message.commentThreads)',
-      },
-      _commentCountText: {
-        type: Number,
-        computed: '_computeCommentCountText(message.commentThreads.length)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _isDeletingChangeMsg: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateExpandedClass(message.expanded)',
-    ];
-  }
-
-  constructor() {
-    super();
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('click',
-        e => this._handleClick(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.restAPI.getConfig().then(config => {
-      this.config = config;
-    });
-    this.$.restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-    this.$.restAPI.getIsAdmin().then(isAdmin => {
-      this._isAdmin = isAdmin;
-    });
-  }
-
-  _updateExpandedClass(expanded) {
-    if (expanded) {
-      this.classList.add('expanded');
-    } else {
-      this.classList.remove('expanded');
-    }
-  }
-
-  _computeCommentCountText(threadsLength) {
-    if (threadsLength === 0) {
-      return undefined;
-    } else if (threadsLength === 1) {
-      return '1 comment';
-    } else {
-      return `${threadsLength} comments`;
-    }
-  }
-
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propagate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(new CustomEvent('comment-refresh', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computeMessageContentExpanded(content, tag) {
-    return this._computeMessageContent(content, tag, true);
-  }
-
-  _patchsetCommentSummary(commentThreads) {
-    const id = this.message.id;
-    if (!id) return '';
-    const patchsetThreads = commentThreads.filter(thread =>
-      thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS);
-    for (const thread of patchsetThreads) {
-      // Find if there was a patchset level comment created through the reply
-      // dialog and use it to determine the summary
-      if (thread.comments[0].change_message_id === id) {
-        return thread.comments[0].message;
-      }
-    }
-    // Find if there is a reply to some patchset comment left
-    for (const thread of patchsetThreads) {
-      for (const comment of thread.comments) {
-        if (comment.change_message_id === id) { return comment.message; }
-      }
-    }
-    return '';
-  }
-
-  _computeMessageContentCollapsed(content, tag, commentThreads) {
-    const summary =
-      this._computeMessageContent(content, tag, false);
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _computeMessageContent(content, tag, isExpanded) {
-    content = content || '';
-    tag = tag || '';
-    const isNewPatchSet = tag.endsWith(':newPatchSet') ||
-        tag.endsWith(':newWipPatchSet');
-    const lines = content.split('\n');
-    const filteredLines = lines.filter(line => {
-      if (!isExpanded && line.startsWith('>')) {
-        return false;
-      }
-      if (line.startsWith('(') && line.endsWith(' comment)')) {
-        return false;
-      }
-      if (line.startsWith('(') && line.endsWith(' comments)')) {
-        return false;
-      }
-      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
-        return false;
-      }
-      return true;
-    });
-    const mappedLines = filteredLines.map(line => {
-      // The change message formatting is not very consistent, so
-      // unfortunately we have to do a bit of tweaking here:
-      //   Labels should be stripped from lines like this:
-      //     Patch Set 29: Verified+1
-      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
-      //   lines like this:
-      //     Patch Set 27: Patch Set 26 was rebased
-      if (isNewPatchSet) {
-        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
-      }
-      return line;
-    });
-    return mappedLines.join('\n').trim();
-  }
-
-  _computeAuthor(message) {
-    return message.author || message.updated_by;
-  }
-
-  _computeShowOnBehalfOf(message) {
-    const author = message.author || message.updated_by;
-    return !!(author && message.real_author &&
-        author._account_id != message.real_author._account_id);
-  }
-
-  _computeShowReplyButton(message, loggedIn) {
-    return message && !!message.message && loggedIn &&
-        !this._computeIsAutomated(message);
-  }
-
-  _computeExpanded(expanded) {
-    return expanded;
-  }
-
-  _handleClick(e) {
-    if (this.message.expanded) { return; }
-    e.stopPropagation();
-    this.set('message.expanded', true);
-  }
-
-  _handleAuthorClick(e) {
-    if (!this.message.expanded) { return; }
-    e.stopPropagation();
-    this.set('message.expanded', false);
-  }
-
-  _computeIsAutomated(message) {
-    return !!(message.reviewer ||
-        this._computeIsReviewerUpdate(message) ||
-        (message.tag && message.tag.startsWith('autogenerated')));
-  }
-
-  _computeIsHidden(hideAutomated, isAutomated) {
-    return hideAutomated && isAutomated;
-  }
-
-  _computeIsReviewerUpdate(message) {
-    return message.type === 'REVIEWER_UPDATE';
-  }
-
-  _getScores(message, labelExtremes) {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw.split(' ')
-        .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-        .filter(ms =>
-          ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
-        .map(ms => {
-          const label = ms[2];
-          const value = ms[1] === '-' ? 'removed' : ms[3];
-          return {label, value};
-        });
-  }
-
-  _computeScoreClass(score, labelExtremes) {
-    // Polymer 2: check for undefined
-    if ([score, labelExtremes].includes(undefined)) {
-      return '';
-    }
-    if (score.value === 'removed') {
-      return 'removed';
-    }
-    const classes = [];
-    if (score.value > 0) {
-      classes.push('positive');
-    } else if (score.value < 0) {
-      classes.push('negative');
-    }
-    const extremes = labelExtremes[score.label];
-    if (extremes) {
-      const intScore = parseInt(score.value, 10);
-      if (intScore === extremes.max) {
-        classes.push('max');
-      } else if (intScore === extremes.min) {
-        classes.push('min');
-      }
-    }
-    return classes.join(' ');
-  }
-
-  _computeClass(expanded) {
-    const classes = [];
-    classes.push(expanded ? 'expanded' : 'collapsed');
-    return classes.join(' ');
-  }
-
-  _handleAnchorClick(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('message-anchor-tap', {
-      bubbles: true,
-      composed: true,
-      detail: {id: this.message.id},
-    }));
-  }
-
-  _handleReplyTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('reply', {
-      detail: {message: this.message},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleDeleteMessage(e) {
-    e.preventDefault();
-    if (!this.message || !this.message.id) return;
-    this._isDeletingChangeMsg = true;
-    this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
-        .then(() => {
-          this._isDeletingChangeMsg = false;
-          this.dispatchEvent(new CustomEvent('change-message-deleted', {
-            detail: {message: this.message},
-            composed: true, bubbles: true,
-          }));
-        });
-  }
-
-  _projectNameChanged(name) {
-    this.$.restAPI.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeExpandToggleIcon(expanded) {
-    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _toggleExpanded(e) {
-    e.stopPropagation();
-    this.set('message.expanded', !this.message.expanded);
-  }
-}
-
-customElements.define(GrMessage.is, GrMessage);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
new file mode 100644
index 0000000..2b5d94d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -0,0 +1,493 @@
+/**
+ * @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-icon/iron-icon';
+import '../../shared/gr-account-label/gr-account-label';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-voting-styles';
+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-message_html';
+import {SpecialFilePath} from '../../../constants/constants';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ChangeMessageInfo,
+  ServerInfo,
+  ConfigInfo,
+  RepoName,
+  ReviewInputTag,
+  VotingRangeInfo,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message': GrMessage;
+  }
+}
+
+export interface GrMessage {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+@customElement('gr-message')
+export class GrMessage extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when this message's reply link is tapped.
+   *
+   * @event reply
+   */
+
+  /**
+   * Fired when the message's timestamp is tapped.
+   *
+   * @event message-anchor-tap
+   */
+
+  /**
+   * Fired when a change message is deleted.
+   *
+   * @event change-message-deleted
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Number})
+  changeNum?: number;
+
+  @property({type: Object})
+  message: ChangeMessage | undefined;
+
+  @computed('message')
+  get author() {
+    return this.message?.author || this.message?.updated_by;
+  }
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Boolean})
+  hideAutomated = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    computed: '_computeIsHidden(hideAutomated, isAutomated)',
+  })
+  hidden = false;
+
+  @computed('message')
+  get isAutomated() {
+    return !!this.message && this._computeIsAutomated(this.message);
+  }
+
+  @computed('message')
+  get showOnBehalfOf() {
+    return !!this.message && this._computeShowOnBehalfOf(this.message);
+  }
+
+  @property({
+    type: Boolean,
+    computed: '_computeShowReplyButton(message, _loggedIn)',
+  })
+  showReplyButton = false;
+
+  @property({type: String})
+  projectName?: string;
+
+  /**
+   * A mapping from label names to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  @property({type: Object})
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _isDeletingChangeMsg = false;
+
+  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
+  _expanded = false;
+
+  @property({
+    type: String,
+    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+  })
+  _messageContentExpanded = '';
+
+  @property({
+    type: String,
+    computed:
+      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      ' message.commentThreads)',
+  })
+  _messageContentCollapsed = '';
+
+  @property({
+    type: String,
+    computed: '_computeCommentCountText(message.commentThreads.length)',
+  })
+  _commentCountText = '';
+
+  created() {
+    super.created();
+    this.addEventListener('click', e => this._handleClick(e));
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this.config = config;
+    });
+    this.$.restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this.$.restAPI.getIsAdmin().then(isAdmin => {
+      this._isAdmin = !!isAdmin;
+    });
+  }
+
+  @observe('message.expanded')
+  _updateExpandedClass(expanded: boolean) {
+    if (expanded) {
+      this.classList.add('expanded');
+    } else {
+      this.classList.remove('expanded');
+    }
+  }
+
+  _computeCommentCountText(threadsLength?: number) {
+    if (threadsLength === 0) {
+      return undefined;
+    } else if (threadsLength === 1) {
+      return '1 comment';
+    } else {
+      return `${threadsLength} comments`;
+    }
+  }
+
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propagate the changes to the files
+    // should consider replacing this with either top level events
+    // or gerrit level events
+
+    // emit the event so change-view can also get updated with latest changes
+    this.dispatchEvent(
+      new CustomEvent('comment-refresh', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
+    return this._computeMessageContent(content, tag, true);
+  }
+
+  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+    const id = this.message?.id;
+    if (!id) return '';
+    const patchsetThreads = commentThreads.filter(
+      thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+    );
+    for (const thread of patchsetThreads) {
+      // Find if there was a patchset level comment created through the reply
+      // dialog and use it to determine the summary
+      if (thread.comments[0].change_message_id === id) {
+        return thread.comments[0].message;
+      }
+    }
+    // Find if there is a reply to some patchset comment left
+    for (const thread of patchsetThreads) {
+      for (const comment of thread.comments) {
+        if (comment.change_message_id === id) {
+          return comment.message;
+        }
+      }
+    }
+    return '';
+  }
+
+  _computeMessageContentCollapsed(
+    content?: string,
+    tag?: ReviewInputTag,
+    commentThreads?: CommentThread[]
+  ) {
+    const summary = this._computeMessageContent(content, tag, false);
+    if (summary || !commentThreads) return summary;
+    return this._patchsetCommentSummary(commentThreads);
+  }
+
+  _computeMessageContent(
+    content = '',
+    tag: ReviewInputTag = '' as ReviewInputTag,
+    isExpanded: boolean
+  ) {
+    const isNewPatchSet =
+      tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+    const lines = content.split('\n');
+    const filteredLines = lines.filter(line => {
+      if (!isExpanded && line.startsWith('>')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comment)')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comments)')) {
+        return false;
+      }
+      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
+        return false;
+      }
+      return true;
+    });
+    const mappedLines = filteredLines.map(line => {
+      // The change message formatting is not very consistent, so
+      // unfortunately we have to do a bit of tweaking here:
+      //   Labels should be stripped from lines like this:
+      //     Patch Set 29: Verified+1
+      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
+      //   lines like this:
+      //     Patch Set 27: Patch Set 26 was rebased
+      if (isNewPatchSet) {
+        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
+      }
+      return line;
+    });
+    return mappedLines.join('\n').trim();
+  }
+
+  _computeAuthor(message: ChangeMessage) {
+    return message.author || message.updated_by;
+  }
+
+  _computeShowOnBehalfOf(message: ChangeMessage) {
+    const author = this._computeAuthor(message);
+    return !!(
+      author &&
+      message.real_author &&
+      author._account_id !== message.real_author._account_id
+    );
+  }
+
+  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+    return (
+      message &&
+      !!message.message &&
+      loggedIn &&
+      !this._computeIsAutomated(message)
+    );
+  }
+
+  _computeExpanded(expanded: boolean) {
+    return expanded;
+  }
+
+  _handleClick(e: Event) {
+    if (this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', true);
+  }
+
+  _handleAuthorClick(e: Event) {
+    if (!this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', false);
+  }
+
+  _computeIsAutomated(message: ChangeMessage) {
+    return !!(
+      message.reviewer ||
+      this._computeIsReviewerUpdate(message) ||
+      (message.tag && message.tag.startsWith('autogenerated'))
+    );
+  }
+
+  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
+    return hideAutomated && isAutomated;
+  }
+
+  _computeIsReviewerUpdate(message: ChangeMessage) {
+    return message.type === 'REVIEWER_UPDATE';
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw
+      .split(' ')
+      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+      .filter(
+        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        return {label, value};
+      });
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    // Polymer 2: check for undefined
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value === 'removed') {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _computeClass(expanded: boolean) {
+    const classes = [];
+    classes.push(expanded ? 'expanded' : 'collapsed');
+    return classes.join(' ');
+  }
+
+  _handleAnchorClick(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {id: this.message?.id},
+      })
+    );
+  }
+
+  _handleReplyTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('reply', {
+        detail: {message: this.message},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleDeleteMessage(e: Event) {
+    e.preventDefault();
+    if (!this.message || !this.message.id || !this.changeNum) return;
+    this._isDeletingChangeMsg = true;
+    this.$.restAPI
+      .deleteChangeCommitMessage(this.changeNum, this.message.id)
+      .then(() => {
+        this._isDeletingChangeMsg = false;
+        this.dispatchEvent(
+          new CustomEvent('change-message-deleted', {
+            detail: {message: this.message},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+  }
+
+  @observe('projectName')
+  _projectNameChanged(name: string) {
+    this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _computeExpandToggleIcon(expanded: boolean) {
+    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _toggleExpanded(e: Event) {
+    e.stopPropagation();
+    this.set('message.expanded', !this.message?.expanded);
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index c617d2c..66b8ed2 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -80,6 +80,7 @@
     });
 
     test('delete change message', done => {
+      element.changeNum = 314159;
       element.message = {
         id: '47c43261_55aa2c41',
         author: {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
deleted file mode 100644
index 7608137..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ /dev/null
@@ -1,132 +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 '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-avatar/gr-avatar.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-account-dropdown_html.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrAccountDropdown extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-dropdown'; }
-
-  static get properties() {
-    return {
-      account: Object,
-      config: Object,
-      links: {
-        type: Array,
-        computed: '_getLinks(_switchAccountUrl, _path)',
-      },
-      topContent: {
-        type: Array,
-        computed: '_getTopContent(account)',
-      },
-      _path: {
-        type: String,
-        value: '/',
-      },
-      _hasAvatars: Boolean,
-      _switchAccountUrl: String,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._handleLocationChange();
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.$.restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-
-      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-        this._switchAccountUrl = cfg.auth.switch_account_url;
-      } else {
-        this._switchAccountUrl = '';
-      }
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
-  }
-
-  _getLinks(switchAccountUrl, path) {
-    // Polymer 2: check for undefined
-    if ([switchAccountUrl, path].includes(undefined)) {
-      return undefined;
-    }
-
-    const links = [];
-    links.push({name: 'Settings', url: '/settings/'});
-    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
-    if (switchAccountUrl) {
-      const replacements = {path};
-      const url = this._interpolateUrl(switchAccountUrl, replacements);
-      links.push({name: 'Switch account', url, external: true});
-    }
-    links.push({name: 'Sign out', url: '/logout'});
-    return links;
-  }
-
-  _getTopContent(account) {
-    return [
-      {text: this._accountName(account), bold: true},
-      {text: account.email ? account.email : ''},
-    ];
-  }
-
-  _handleShortcutsTap(e) {
-    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
-        {bubbles: true, composed: true}));
-  }
-
-  _handleLocationChange() {
-    this._path =
-        window.location.pathname +
-        window.location.search +
-        window.location.hash;
-  }
-
-  _interpolateUrl(url, replacements) {
-    return url.replace(
-        INTERPOLATE_URL_PATTERN,
-        (match, p1) => replacements[p1] || '');
-  }
-
-  _accountName(account) {
-    return getUserName(this.config, account);
-  }
-}
-
-customElements.define(GrAccountDropdown.is, GrAccountDropdown);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
new file mode 100644
index 0000000..ef0ced8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -0,0 +1,146 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../shared/gr-avatar/gr-avatar';
+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-account-dropdown_html';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-dropdown': GrAccountDropdown;
+  }
+}
+
+export interface GrAccountDropdown {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-account-dropdown')
+export class GrAccountDropdown extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
+  links?: string[];
+
+  @property({type: Array, computed: '_getTopContent(account)'})
+  topContent?: string[];
+
+  @property({type: String})
+  _path = '/';
+
+  @property({type: Boolean})
+  _hasAvatars = false;
+
+  @property({type: String})
+  _switchAccountUrl = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._handleLocationChange();
+    this.listen(window, 'location-change', '_handleLocationChange');
+    this.$.restAPI.getConfig().then(cfg => {
+      this.config = cfg;
+
+      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+        this._switchAccountUrl = cfg.auth.switch_account_url;
+      } else {
+        this._switchAccountUrl = '';
+      }
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _getLinks(switchAccountUrl: string, path: string) {
+    // Polymer 2: check for undefined
+    if (switchAccountUrl === undefined || path === undefined) {
+      return undefined;
+    }
+
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
+    if (switchAccountUrl) {
+      const replacements = {path};
+      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      links.push({name: 'Switch account', url, external: true});
+    }
+    links.push({name: 'Sign out', url: '/logout'});
+    return links;
+  }
+
+  _getTopContent(account?: AccountInfo) {
+    return [
+      {text: this._accountName(account), bold: true},
+      {text: account?.email ? account.email : ''},
+    ];
+  }
+
+  _handleShortcutsTap() {
+    this.dispatchEvent(
+      new CustomEvent('show-keyboard-shortcuts', {
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _handleLocationChange() {
+    this._path =
+      window.location.pathname + window.location.search + window.location.hash;
+  }
+
+  _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+    return url.replace(
+      INTERPOLATE_URL_PATTERN,
+      (_, p1) => replacements[p1] || ''
+    );
+  }
+
+  _accountName(account?: AccountInfo) {
+    return getUserName(this.config, account);
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 37d8819..cd43fdb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -979,12 +979,6 @@
     assert.equal(element.$.diff.displayLine, value);
   });
 
-  test('passes in commitRange', () => {
-    const value = {};
-    element.commitRange = value;
-    assert.equal(element.$.diff.commitRange, value);
-  });
-
   test('passes in hidden', () => {
     const value = true;
     element.hidden = value;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 1c52b5f..2502e45 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -361,28 +361,31 @@
     });
   }
 
+  // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
+  // other users of gr-diff may use different comment widgets.
   _updateRanges(
     addedThreadEls: GrCommentThread[],
     removedThreadEls: GrCommentThread[]
   ) {
     function commentRangeFromThreadEl(
       threadEl: GrCommentThread
-    ): CommentRangeLayer {
+    ): CommentRangeLayer | undefined {
       const side = getSide(threadEl);
 
       const rangeAtt = threadEl.getAttribute('range');
-      if (!rangeAtt) throw Error('comment thread without range');
+      if (!rangeAtt) return undefined;
       const range = JSON.parse(rangeAtt) as CommentRange;
 
       return {side, range, hovering: false, rootId: threadEl.rootId};
     }
 
+    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
     const addedCommentRanges = addedThreadEls
       .map(commentRangeFromThreadEl)
-      .filter(({range}) => range);
+      .filter(range => !!range) as CommentRangeLayer[];
     const removedCommentRanges = removedThreadEls
       .map(commentRangeFromThreadEl)
-      .filter(({range}) => range);
+      .filter(range => !!range) as CommentRangeLayer[];
     for (const removedCommentRange of removedCommentRanges) {
       const i = this._commentRanges.findIndex(
         cr =>
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
similarity index 69%
rename from polygerrit-ui/app/elements/gr-app-global-var-init.js
rename to polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 07d37fa..e609e6f 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,53 +22,81 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {getAccountDisplayName, getDisplayName, getGroupDisplayName, getUserName} from '../utils/display-name-util.js';
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
-import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group.js';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder.js';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side.js';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image.js';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified.js';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary.js';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api.js';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api.js';
-import {GrEditConstants} from './edit/gr-edit-constants.js';
-import {GrDomHooksManager, GrDomHook} from './plugins/gr-dom-hooks/gr-dom-hooks.js';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator.js';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
-import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser.js';
-import {getPluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {util} from '../scripts/util.js';
-import {page} from '../utils/page-wrapper-utils.js';
-import {appContext} from '../services/app-context.js';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api.js';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper.js';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api.js';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api.js';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
-import {getPluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
-import {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
-import {getBaseUrl} from '../utils/url-util.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {getRootElement} from '../scripts/rootElement.js';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
-import {RevisionInfo} from './shared/revision-info/revision-info.js';
-import {CoverageType} from '../types/types.js';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll.js';
+import {
+  getAccountDisplayName,
+  getDisplayName,
+  getGroupDisplayName,
+  getUserName,
+} from '../utils/display-name-util';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
+import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
+import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
+import {GrEditConstants} from './edit/gr-edit-constants';
+import {
+  GrDomHooksManager,
+  GrDomHook,
+} from './plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
+import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
+import {
+  SiteBasedCache,
+  FetchPromisesCache,
+  GrRestApiHelper,
+} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
+import {
+  getPluginEndpoints,
+  GrPluginEndpoints,
+} from './shared/gr-js-api-interface/gr-plugin-endpoints';
+import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
+import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {util} from '../scripts/util';
+import {page} from '../utils/page-wrapper-utils';
+import {appContext} from '../services/app-context';
+import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
+import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
+import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
+import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
+import {
+  getPluginLoader,
+  PluginLoader,
+} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
+import {
+  getPluginNameFromUrl,
+  getRestAPI,
+  PLUGIN_LOADING_TIMEOUT_MS,
+  PRELOADED_PROTOCOL,
+  send,
+} from './shared/gr-js-api-interface/gr-api-utils';
+import {getBaseUrl} from '../utils/url-util';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {getRootElement} from '../scripts/rootElement';
+import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
+import {RevisionInfo} from './shared/revision-info/revision-info';
+import {CoverageType} from '../types/types';
+import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
+import {GerritGlobal} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrDisplayNameUtils = {
@@ -131,7 +159,7 @@
     PLUGIN_LOADING_TIMEOUT_MS,
   };
 
-  window.Gerrit = window.Gerrit || {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   window.Gerrit.Nav = GerritNav;
   window.Gerrit.getRootElement = getRootElement;
   window.Gerrit.Auth = appContext.authService;
@@ -140,10 +168,10 @@
   // TODO: should define as a getter
   window.Gerrit._endpoints = getPluginEndpoints();
 
-  window.Gerrit.slotToContent = slot => slot;
+  // TODO(TS): seems not used, probably just remove
+  window.Gerrit.slotToContent = (slot: any) => slot;
   window.Gerrit.rangesEqual = rangesEqual;
-  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES =
-      SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
   window.Gerrit.RevisionInfo = RevisionInfo;
   window.Gerrit.CoverageType = CoverageType;
   Object.defineProperty(window.Gerrit, 'hiddenscroll', {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
deleted file mode 100644
index 76899b3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ /dev/null
@@ -1,369 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../gr-account-chip/gr-account-chip.js';
-import '../gr-account-entry/gr-account-entry.js';
-import '../../../styles/shared-styles.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-account-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-
-const VALID_EMAIL_ALERT = 'Please input a valid email.';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountList extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-list'; }
-  /**
-   * Fired when user inputs an invalid email address.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      accounts: {
-        type: Array,
-        value() { return []; },
-        notify: true,
-      },
-      change: Object,
-      filter: Function,
-      placeholder: String,
-      disabled: {
-        type: Function,
-        value: false,
-      },
-
-      /**
-       * Returns suggestions and convert them to list item
-       *
-       * @type {Gerrit.GrSuggestionsProvider}
-       */
-      suggestionsProvider: {
-        type: Object,
-      },
-
-      /**
-       * Needed for template checking since value is initially set to null.
-       *
-       * @type {?Object}
-       */
-      pendingConfirmation: {
-        type: Object,
-        value: null,
-        notify: true,
-      },
-      readonly: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * When true, allows for non-suggested inputs to be added.
-       */
-      allowAnyInput: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Array of values (groups/accounts) that are removable. When this prop is
-       * undefined, all values are removable.
-       */
-      removableValues: Array,
-      maxCount: {
-        type: Number,
-        value: 0,
-      },
-
-      /**
-       * Returns suggestion items
-       *
-       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
-       */
-      _querySuggestions: {
-        type: Function,
-        value() {
-          return input => this._getSuggestions(input);
-        },
-      },
-
-      /**
-       * Set to true to disable suggestions on empty input.
-       */
-      skipSuggestOnEmpty: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('remove',
-        e => this._handleRemove(e));
-  }
-
-  get accountChips() {
-    return Array.from(
-        this.root.querySelectorAll('gr-account-chip'));
-  }
-
-  get focusStart() {
-    return this.$.entry.focusStart;
-  }
-
-  _getSuggestions(input) {
-    if (this.skipSuggestOnEmpty && !input) {
-      return Promise.resolve([]);
-    }
-    const provider = this.suggestionsProvider;
-    if (!provider) {
-      return Promise.resolve([]);
-    }
-    return provider.getSuggestions(input).then(suggestions => {
-      if (!suggestions) { return []; }
-      if (this.filter) {
-        suggestions = suggestions.filter(this.filter);
-      }
-      return suggestions.map(suggestion =>
-        provider.makeSuggestionItem(suggestion));
-    });
-  }
-
-  _handleAdd(e) {
-    this.addAccountItem(e.detail.value);
-  }
-
-  addAccountItem(item) {
-    // Append new account or group to the accounts property. We add our own
-    // internal properties to the account/group here, so we clone the object
-    // to avoid cluttering up the shared change object.
-    let itemTypeAdded = 'unknown';
-    if (item.account) {
-      const account =
-          {...item.account, _pendingAdd: true};
-      this.push('accounts', account);
-      itemTypeAdded = 'account';
-    } else if (item.group) {
-      if (item.confirm) {
-        this.pendingConfirmation = item;
-        return;
-      }
-      const group = {...item.group,
-        _pendingAdd: true, _group: true};
-      this.push('accounts', group);
-      itemTypeAdded = 'group';
-    } else if (this.allowAnyInput) {
-      if (!item.includes('@')) {
-        // Repopulate the input with what the user tried to enter and have
-        // a toast tell them why they can't enter it.
-        this.$.entry.setText(item);
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {message: VALID_EMAIL_ALERT},
-          bubbles: true,
-          composed: true,
-        }));
-        return false;
-      } else {
-        const account = {email: item, _pendingAdd: true};
-        this.push('accounts', account);
-        itemTypeAdded = 'email';
-      }
-    }
-
-    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
-    this.pendingConfirmation = null;
-    return true;
-  }
-
-  confirmGroup(group) {
-    group = {
-      ...group, confirmed: true, _pendingAdd: true, _group: true};
-    this.push('accounts', group);
-    this.pendingConfirmation = null;
-  }
-
-  _computeChipClass(account) {
-    const classes = [];
-    if (account._group) {
-      classes.push('group');
-    }
-    if (account._pendingAdd) {
-      classes.push('pendingAdd');
-    }
-    return classes.join(' ');
-  }
-
-  _accountMatches(a, b) {
-    if (a && b) {
-      if (a._account_id) {
-        return a._account_id === b._account_id;
-      }
-      if (a.email) {
-        return a.email === b.email;
-      }
-    }
-    return a === b;
-  }
-
-  _computeRemovable(account, readonly) {
-    if (readonly) { return false; }
-    if (this.removableValues) {
-      for (let i = 0; i < this.removableValues.length; i++) {
-        if (this._accountMatches(this.removableValues[i], account)) {
-          return true;
-        }
-      }
-      return !!account._pendingAdd;
-    }
-    return true;
-  }
-
-  _handleRemove(e) {
-    const toRemove = e.detail.account;
-    this.removeAccount(toRemove);
-    this.$.entry.focus();
-  }
-
-  removeAccount(toRemove) {
-    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
-      return;
-    }
-    for (let i = 0; i < this.accounts.length; i++) {
-      let matches;
-      const account = this.accounts[i];
-      if (toRemove._group) {
-        matches = toRemove.id === account.id;
-      } else {
-        matches = this._accountMatches(toRemove, account);
-      }
-      if (matches) {
-        this.splice('accounts', i, 1);
-        this.reporting.reportInteraction(`Remove from ${this.id}`);
-        return;
-      }
-    }
-    console.warn('received remove event for missing account', toRemove);
-  }
-
-  _getNativeInput(paperInput) {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return paperInput.$.nativeInput || paperInput.inputElement;
-  }
-
-  _handleInputKeydown(e) {
-    const input = this._getNativeInput(e.detail.input);
-    if (input.selectionStart !== input.selectionEnd ||
-        input.selectionStart !== 0) {
-      return;
-    }
-    switch (e.detail.keyCode) {
-      case 8: // Backspace
-        this.removeAccount(this.accounts[this.accounts.length - 1]);
-        break;
-      case 37: // Left arrow
-        if (this.accountChips[this.accountChips.length - 1]) {
-          this.accountChips[this.accountChips.length - 1].focus();
-        }
-        break;
-    }
-  }
-
-  _handleChipKeydown(e) {
-    const chip = e.target;
-    const chips = this.accountChips;
-    const index = chips.indexOf(chip);
-    switch (e.keyCode) {
-      case 8: // Backspace
-      case 13: // Enter
-      case 32: // Spacebar
-      case 46: // Delete
-        this.removeAccount(chip.account);
-        // Splice from this array to avoid inconsistent ordering of
-        // event handling.
-        chips.splice(index, 1);
-        if (index < chips.length) {
-          chips[index].focus();
-        } else if (index > 0) {
-          chips[index - 1].focus();
-        } else {
-          this.$.entry.focus();
-        }
-        break;
-      case 37: // Left arrow
-        if (index > 0) {
-          chip.blur();
-          chips[index - 1].focus();
-        }
-        break;
-      case 39: // Right arrow
-        chip.blur();
-        if (index < chips.length - 1) {
-          chips[index + 1].focus();
-        } else {
-          this.$.entry.focus();
-        }
-        break;
-    }
-  }
-
-  /**
-   * Submit the text of the entry as a reviewer value, if it exists. If it is
-   * a successful submit of the text, clear the entry value.
-   *
-   * @return {boolean} If there is text in the entry, return true if the
-   *     submission was successful and false if not. If there is no text,
-   *     return true.
-   */
-  submitEntryText() {
-    const text = this.$.entry.getText();
-    if (!text.length) { return true; }
-    const wasSubmitted = this.addAccountItem(text);
-    if (wasSubmitted) { this.$.entry.clear(); }
-    return wasSubmitted;
-  }
-
-  additions() {
-    return this.accounts
-        .filter(account => account._pendingAdd)
-        .map(account => {
-          if (account._group) {
-            return {group: account};
-          } else {
-            return {account};
-          }
-        });
-  }
-
-  _computeEntryHidden(maxCount, accountsRecord, readonly) {
-    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
-  }
-}
-
-customElements.define(GrAccountList.is, GrAccountList);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
new file mode 100644
index 0000000..113a881
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -0,0 +1,452 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-account-chip/gr-account-chip';
+import '../gr-account-entry/gr-account-entry';
+import '../../../styles/shared-styles';
+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-account-list_html';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  Suggestion,
+  AccountInfo,
+  GroupInfo,
+} from '../../../types/common';
+import {
+  GrReviewerSuggestionsProvider,
+  SuggestionItem,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-list': GrAccountList;
+  }
+}
+
+export interface GrAccountList {
+  $: {
+    entry: GrAccountEntry;
+  };
+}
+
+/**
+ * For item added with account info
+ */
+export interface AccountObjectInput {
+  account: AccountInfo;
+}
+
+/**
+ * For item added with group info
+ */
+export interface GroupObjectInput {
+  group: GroupInfo;
+  confirm: boolean;
+}
+
+/** Supported input to be added */
+export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+
+// type guards for AccountObjectInput and GroupObjectInput
+function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
+  return !!(x as AccountObjectInput).account;
+}
+
+function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
+  return !!(x as GroupObjectInput).group;
+}
+
+// Internal input type with account info
+interface AccountInfoInput extends AccountInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+// Internal input type with group info
+interface GroupInfoInput extends GroupInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+type AccountInput = AccountInfoInput | GroupInfoInput;
+
+@customElement('gr-account-list')
+export class GrAccountList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when user inputs an invalid email address.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Array, notify: true})
+  accounts: AccountInput[] = [];
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  filter?: (input: Suggestion) => boolean;
+
+  @property({type: String})
+  placeholder = '';
+
+  @property({type: Boolean})
+  disabled = false;
+
+  /**
+   * Returns suggestions and convert them to list item
+   */
+  @property({type: Object})
+  suggestionsProvider?: GrReviewerSuggestionsProvider;
+
+  /**
+   * Needed for template checking since value is initially set to null.
+   */
+  @property({type: Object, notify: true})
+  pendingConfirmation: RawAccountInput | null = null;
+
+  @property({type: Boolean})
+  readonly = false;
+
+  /**
+   * When true, allows for non-suggested inputs to be added.
+   */
+  @property({type: Boolean})
+  allowAnyInput = false;
+
+  /**
+   * Array of values (groups/accounts) that are removable. When this prop is
+   * undefined, all values are removable.
+   */
+  @property({type: Array})
+  removableValues?: AccountInput[];
+
+  @property({type: Number})
+  maxCount = 0;
+
+  /**
+   * Returns suggestion items
+   */
+  @property({type: Object})
+  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+
+  /**
+   * Set to true to disable suggestions on empty input.
+   */
+  @property({type: Boolean})
+  skipSuggestOnEmpty = false;
+
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+    this._querySuggestions = input => this._getSuggestions(input);
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('remove', e =>
+      this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+    );
+  }
+
+  get accountChips() {
+    return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+  }
+
+  get focusStart() {
+    return this.$.entry.focusStart;
+  }
+
+  _getSuggestions(input: string) {
+    if (this.skipSuggestOnEmpty && !input) {
+      return Promise.resolve([]);
+    }
+    const provider = this.suggestionsProvider;
+    if (!provider) {
+      return Promise.resolve([]);
+    }
+    return provider.getSuggestions(input).then(suggestions => {
+      if (!suggestions) {
+        return [];
+      }
+      if (this.filter) {
+        suggestions = suggestions.filter(this.filter);
+      }
+      return suggestions.map(suggestion =>
+        provider.makeSuggestionItem(suggestion)
+      );
+    });
+  }
+
+  _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
+    this.addAccountItem(e.detail.value);
+  }
+
+  addAccountItem(item: RawAccountInput) {
+    // Append new account or group to the accounts property. We add our own
+    // internal properties to the account/group here, so we clone the object
+    // to avoid cluttering up the shared change object.
+    let itemTypeAdded = 'unknown';
+    if (isAccountObject(item)) {
+      const account = {...item.account, _pendingAdd: true};
+      this.push('accounts', account);
+      itemTypeAdded = 'account';
+    } else if (isGroupObjectInput(item)) {
+      if (item.confirm) {
+        this.pendingConfirmation = item;
+        return;
+      }
+      const group = {...item.group, _pendingAdd: true, _group: true};
+      this.push('accounts', group);
+      itemTypeAdded = 'group';
+    } else if (this.allowAnyInput) {
+      if (!item.includes('@')) {
+        // Repopulate the input with what the user tried to enter and have
+        // a toast tell them why they can't enter it.
+        this.$.entry.setText(item);
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: VALID_EMAIL_ALERT},
+            bubbles: true,
+            composed: true,
+          })
+        );
+        return false;
+      } else {
+        const account = {email: item, _pendingAdd: true};
+        this.push('accounts', account);
+        itemTypeAdded = 'email';
+      }
+    }
+
+    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
+    this.pendingConfirmation = null;
+    return true;
+  }
+
+  confirmGroup(group: GroupObjectInput) {
+    this.push('accounts', {
+      ...group,
+      confirmed: true,
+      _pendingAdd: true,
+      _group: true,
+    });
+    this.pendingConfirmation = null;
+  }
+
+  _computeChipClass(account: AccountInput) {
+    const classes = [];
+    if (account._group) {
+      classes.push('group');
+    }
+    if (account._pendingAdd) {
+      classes.push('pendingAdd');
+    }
+    return classes.join(' ');
+  }
+
+  _accountMatches(a: AccountInput, b: AccountInput) {
+    // TODO(TS): seems a & b always exists ?
+    if (a && b) {
+      // both conditions are checking against AccountInfo
+      // and only check a not b.. typeguard won't work very good without
+      // changing logic, so keep it as inline casting
+      if ((a as AccountInfoInput)._account_id) {
+        return (
+          (a as AccountInfoInput)._account_id ===
+          (b as AccountInfoInput)._account_id
+        );
+      }
+      if ((a as AccountInfoInput).email) {
+        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
+      }
+    }
+    return a === b;
+  }
+
+  _computeRemovable(account: AccountInput, readonly: boolean) {
+    if (readonly) {
+      return false;
+    }
+    if (this.removableValues) {
+      for (let i = 0; i < this.removableValues.length; i++) {
+        if (this._accountMatches(this.removableValues[i], account)) {
+          return true;
+        }
+      }
+      return !!account._pendingAdd;
+    }
+    return true;
+  }
+
+  _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+    const toRemove = e.detail.account;
+    this.removeAccount(toRemove);
+    this.$.entry.focus();
+  }
+
+  removeAccount(toRemove?: AccountInput) {
+    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      return;
+    }
+    for (let i = 0; i < this.accounts.length; i++) {
+      let matches;
+      const account = this.accounts[i];
+      if (toRemove._group) {
+        matches =
+          (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
+      } else {
+        matches = this._accountMatches(toRemove, account);
+      }
+      if (matches) {
+        this.splice('accounts', i, 1);
+        this.reporting.reportInteraction(`Remove from ${this.id}`);
+        return;
+      }
+    }
+    console.warn('received remove event for missing account', toRemove);
+  }
+
+  _getNativeInput(paperInput: PaperInputElement) {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (paperInput.$.nativeInput ||
+      paperInput.inputElement) as HTMLTextAreaElement;
+  }
+
+  _handleInputKeydown(
+    e: CustomEvent<{input: PaperInputElement; keyCode: number}>
+  ) {
+    const input = this._getNativeInput(e.detail.input);
+    if (
+      input.selectionStart !== input.selectionEnd ||
+      input.selectionStart !== 0
+    ) {
+      return;
+    }
+    switch (e.detail.keyCode) {
+      case 8: // Backspace
+        this.removeAccount(this.accounts[this.accounts.length - 1]);
+        break;
+      case 37: // Left arrow
+        if (this.accountChips[this.accountChips.length - 1]) {
+          this.accountChips[this.accountChips.length - 1].focus();
+        }
+        break;
+    }
+  }
+
+  _handleChipKeydown(e: KeyboardEvent) {
+    const chip = e.target as GrAccountChip;
+    const chips = this.accountChips;
+    const index = chips.indexOf(chip);
+    switch (e.keyCode) {
+      case 8: // Backspace
+      case 13: // Enter
+      case 32: // Spacebar
+      case 46: // Delete
+        this.removeAccount(chip.account);
+        // Splice from this array to avoid inconsistent ordering of
+        // event handling.
+        chips.splice(index, 1);
+        if (index < chips.length) {
+          chips[index].focus();
+        } else if (index > 0) {
+          chips[index - 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+      case 37: // Left arrow
+        if (index > 0) {
+          chip.blur();
+          chips[index - 1].focus();
+        }
+        break;
+      case 39: // Right arrow
+        chip.blur();
+        if (index < chips.length - 1) {
+          chips[index + 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+    }
+  }
+
+  /**
+   * Submit the text of the entry as a reviewer value, if it exists. If it is
+   * a successful submit of the text, clear the entry value.
+   *
+   * @return If there is text in the entry, return true if the
+   * submission was successful and false if not. If there is no text,
+   * return true.
+   */
+  submitEntryText() {
+    const text = this.$.entry.getText();
+    if (!text.length) {
+      return true;
+    }
+    const wasSubmitted = this.addAccountItem(text);
+    if (wasSubmitted) {
+      this.$.entry.clear();
+    }
+    return wasSubmitted;
+  }
+
+  additions() {
+    return this.accounts
+      .filter(account => account._pendingAdd)
+      .map(account => {
+        if (account._group) {
+          return {group: account};
+        } else {
+          return {account};
+        }
+      });
+  }
+
+  _computeEntryHidden(
+    maxCount: number,
+    accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
+    readonly: boolean
+  ) {
+    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 7f0f5b7..9e9a0ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -19,7 +19,11 @@
  * This defines the Gerrit instance. All methods directly attached to Gerrit
  * should be defined or linked here.
  */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
+import {
+  getPluginLoader,
+  PluginOptionMap,
+  PluginLoader,
+} from './gr-plugin-loader';
 import {getRestAPI, send} from './gr-api-utils';
 import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../plugins/gr-plugin-types';
@@ -29,8 +33,15 @@
   EventCallback,
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {rangesEqual} from '../../diff/gr-diff/gr-diff-utils';
+import {SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {CoverageType} from '../../../types/types';
+import {RevisionInfo} from '../revision-info/revision-info';
 
-interface GerritGlobal extends EventEmitterService {
+export interface GerritGlobal extends EventEmitterService {
   flushPreinstalls?(): void;
   css(rule: string): string;
   install(
@@ -60,6 +71,18 @@
   _isPluginLoaded(pathOrUrl: string): boolean;
   _eventEmitter: EventEmitterService;
   _customStyleSheet: CSSStyleSheet;
+
+  // exposed methods
+  Nav: typeof GerritNav;
+  Auth: typeof appContext.authService;
+  getRootElement: typeof getRootElement;
+  _pluginLoader: PluginLoader;
+  _endpoints: GrPluginEndpoints;
+  slotToContent(slot: unknown): unknown;
+  rangesEqual: typeof rangesEqual;
+  SUGGESTIONS_PROVIDERS_USERS_TYPES: typeof SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  CoverageType: typeof CoverageType;
+  RevisionInfo: typeof RevisionInfo;
 }
 
 /**
@@ -67,7 +90,7 @@
  * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
  */
 function flushPreinstalls() {
-  const Gerrit = window.Gerrit as GerritGlobal;
+  const Gerrit = window.Gerrit;
   if (Gerrit.flushPreinstalls) {
     Gerrit.flushPreinstalls();
   }
@@ -75,9 +98,9 @@
 export const _testOnly_flushPreinstalls = flushPreinstalls;
 
 export function initGerritPluginApi() {
-  window.Gerrit = {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   flushPreinstalls();
-  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+  initGerritPluginsMethods(window.Gerrit);
   // Preloaded plugins should be installed after Gerrit.install() is set,
   // since plugin preloader substitutes Gerrit.install() temporarily.
   // (Gerrit.install() is set in initGerritPluginsMethods)
@@ -86,7 +109,7 @@
 
 export function _testOnly_initGerritPluginApi(): GerritGlobal {
   initGerritPluginApi();
-  return window.Gerrit as GerritGlobal;
+  return window.Gerrit;
 }
 
 export function deprecatedDelete(
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index a2d1e1a..3955cd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -23,7 +23,6 @@
   <style include="shared-styles">
     .placeholder {
       color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-xs);
     }
     .hidden {
       display: none;
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 2ca1118..0ceca3c 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,6 +6,11 @@
     bazel_bin=bazel
 fi
 
+# At least temporarily we want to know what is going on even when all tests are
+# passing, so we have a better chance of debugging what happens in CI test runs
+# that were supposed to catch test failures, but did not.
 ${bazel_bin} test \
       "$@" \
+      --test_verbose_timeout_warnings \
+      --test_output=all \
       //polygerrit-ui:karma_test
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index d3b652a..29cdd1e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -155,6 +155,7 @@
   // TODO(dmfilippov): TS-fix-any unclear what is context
   const catchErrors = function (opt_context?: any) {
     const context = opt_context || window;
+    const oldOnError = context.onerror;
     context.onerror = (
       event: Event | string,
       source?: string,
@@ -162,7 +163,7 @@
       colno?: number,
       error?: Error
     ) => {
-      return onError(context.onerror, event, source, lineno, colno, error);
+      return onError(oldOnError, event, source, lineno, colno, error);
     };
     context.addEventListener(
       'unhandledrejection',
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 9fb2882..18c24d2 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
@@ -74,6 +74,7 @@
   NameToProjectInfoMap,
   ProjectInput,
   AccountId,
+  ChangeMessageId,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -595,4 +596,9 @@
     account: AccountId,
     label: string
   ): Promise<Response>;
+
+  deleteChangeCommitMessage(
+    changeNum: ChangeNum,
+    messageId: ChangeMessageId
+  ): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 004daf7..500187a 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -31,6 +31,7 @@
 import sinon from 'sinon/pkg/sinon-esm.js';
 import {safeTypesBridge} from '../utils/safe-types-util.js';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit.js';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init.js';
 window.sinon = sinon;
 
 security.polymer_resin.install({
@@ -60,6 +61,9 @@
 };
 
 setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(cleanups.length, 0);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 52adb7b..e131b4f 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -410,6 +410,8 @@
 export interface ChangeMessageInfo {
   id: ChangeMessageId;
   author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index bdfb8c4..645c991 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,7 +36,7 @@
     ): void;
     ASSETS_PATH?: string;
     // TODO(TS): define gerrit type
-    Gerrit?: unknown;
+    Gerrit?: any;
     // TODO(TS): define polymer type
     Polymer?: unknown;
     // TODO(TS): remove page when better workaround is found
@@ -51,6 +51,56 @@
       dashboardPage?: string;
     };
     STATIC_RESOURCE_PATH?: string;
+
+    /** Enhancements on Gr elements or utils */
+    // TODO(TS): should clean up those and removing them may break certain plugin behaviors
+    // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
+    // use any for them for now
+    GrDisplayNameUtils: any;
+    GrAnnotation: any;
+    GrAttributeHelper: any;
+    GrDiffLine: any;
+    GrDiffLineType: any;
+    GrDiffGroup: any;
+    GrDiffGroupType: any;
+    GrDiffBuilder: any;
+    GrDiffBuilderSideBySide: any;
+    GrDiffBuilderImage: any;
+    GrDiffBuilderUnified: any;
+    GrDiffBuilderBinary: any;
+    GrChangeActionsInterface: any;
+    GrChangeReplyInterface: any;
+    GrEditConstants: any;
+    GrDomHooksManager: any;
+    GrDomHook: any;
+    GrEtagDecorator: any;
+    GrThemeApi: any;
+    SiteBasedCache: any;
+    FetchPromisesCache: any;
+    GrRestApiHelper: any;
+    GrLinkTextParser: any;
+    GrPluginEndpoints: any;
+    GrReviewerUpdatesParser: any;
+    GrPopupInterface: any;
+    GrCountStringFormatter: any;
+    GrReviewerSuggestionsProvider: any;
+    util: any;
+    Auth: any;
+    EventEmitter: any;
+    GrAdminApi: any;
+    GrAnnotationActionsContext: any;
+    GrAnnotationActionsInterface: any;
+    GrChangeMetadataApi: any;
+    GrEmailSuggestionsProvider: any;
+    GrGroupSuggestionsProvider: any;
+    GrEventHelper: any;
+    GrPluginRestApi: any;
+    GrRepoApi: any;
+    GrSettingsApi: any;
+    GrStylesApi: any;
+    PluginLoader: any;
+    GrPluginActionContext: any;
+    _apiUtils: {};
   }
 
   interface Performance {